utils.js
1 /** 2 * Douban adapter utilities. 3 */ 4 import { ArgumentError, CliError, EmptyResultError } from '@jackwener/opencli/errors'; 5 import { clamp } from '../_shared/common.js'; 6 const DOUBAN_PHOTO_PAGE_SIZE = 30; 7 const MAX_DOUBAN_PHOTOS = 500; 8 const clampLimit = (limit) => clamp(limit || 20, 1, 50); 9 const clampPhotoLimit = (limit) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS); 10 const DOUBAN_SEARCH_READY_SELECTOR = '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a'; 11 const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); 12 function firstNonEmpty(values) { 13 for (const value of values) { 14 const normalized = normalizeText(value); 15 if (normalized) 16 return normalized; 17 } 18 return ''; 19 } 20 function splitDoubanPeople(value) { 21 return normalizeText(value) 22 .split(/\s*\/\s*/) 23 .map((entry) => normalizeText(entry)) 24 .filter(Boolean); 25 } 26 function parseDoubanBookInfoText(infoText) { 27 const lines = String(infoText || '') 28 .replace(/\r/g, '\n') 29 .split('\n') 30 .map((line) => normalizeText(line)) 31 .filter(Boolean); 32 const map = {}; 33 for (const line of lines) { 34 const match = line.match(/^([^::]+)\s*[::]\s*(.*)$/); 35 if (!match) 36 continue; 37 const label = normalizeText(match[1]); 38 const value = normalizeText(match[2]); 39 if (!label) 40 continue; 41 map[label] = value; 42 } 43 return map; 44 } 45 function parseDoubanRating(value) { 46 const normalized = normalizeText(value); 47 if (!normalized) 48 return 0; 49 const parsed = Number.parseFloat(normalized); 50 return Number.isFinite(parsed) ? parsed : 0; 51 } 52 function parseDoubanCount(value) { 53 const normalized = normalizeText(value).replace(/[^\d]/g, ''); 54 if (!normalized) 55 return 0; 56 const parsed = Number.parseInt(normalized, 10); 57 return Number.isFinite(parsed) ? parsed : 0; 58 } 59 function parseDoubanPageCount(value) { 60 const match = normalizeText(value).match(/(\d+)/); 61 if (!match) 62 return null; 63 const parsed = Number.parseInt(match[1], 10); 64 return Number.isFinite(parsed) ? parsed : null; 65 } 66 function extractDoubanPublishYear(value) { 67 const match = normalizeText(value).match(/\b(19|20)\d{2}\b/); 68 return match?.[0] || ''; 69 } 70 function splitDoubanTitle(fullTitle) { 71 const normalized = normalizeText(fullTitle); 72 if (!normalized) 73 return { title: '', originalTitle: '' }; 74 const match = normalized.match(/^([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+(?:\s*[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef·::!?]+)*)\s+(.+)$/); 75 if (!match) { 76 return { title: normalized, originalTitle: '' }; 77 } 78 return { 79 title: normalizeText(match[1]), 80 originalTitle: normalizeText(match[2]), 81 }; 82 } 83 async function ensureDoubanReady(page) { 84 const state = await page.evaluate(` 85 (() => { 86 const title = (document.title || '').trim(); 87 const href = (location.href || '').trim(); 88 const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || ''); 89 return { blocked, title, href }; 90 })() 91 `); 92 if (state?.blocked) { 93 throw new CliError('AUTH_REQUIRED', 'Douban requires a logged-in browser session before these commands can load data.', 'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.'); 94 } 95 } 96 function isDetachedPageError(error) { 97 const message = error instanceof Error ? error.message : String(error || ''); 98 return /Detached while handling command|Debugger is not attached to the tab|Target closed|No tab with id/i.test(message); 99 } 100 async function withDetachedRetry(task, options = {}) { 101 const attempts = Math.max(1, options.attempts || 2); 102 let lastError; 103 for (let attempt = 0; attempt < attempts; attempt += 1) { 104 try { 105 return await task(); 106 } 107 catch (error) { 108 lastError = error; 109 if (attempt >= attempts - 1 || !isDetachedPageError(error)) { 110 throw error; 111 } 112 } 113 } 114 throw lastError; 115 } 116 function buildDoubanSearchUrl(type, keyword) { 117 const url = new URL(`https://search.douban.com/${encodeURIComponent(type)}/subject_search`); 118 url.searchParams.set('search_text', String(keyword || '')); 119 if (String(type || '').trim() === 'book') { 120 url.searchParams.set('cat', '1001'); 121 } 122 return url.toString(); 123 } 124 export function normalizeDoubanSubjectId(subjectId) { 125 const normalized = String(subjectId || '').trim(); 126 if (!/^\d+$/.test(normalized)) { 127 throw new ArgumentError(`Invalid Douban subject ID: ${subjectId}`); 128 } 129 return normalized; 130 } 131 export function promoteDoubanPhotoUrl(url, size = 'l') { 132 const normalized = String(url || '').trim(); 133 if (!normalized) 134 return ''; 135 if (/^[a-z]+:/i.test(normalized) && !/^https?:/i.test(normalized)) 136 return ''; 137 return normalized.replace(/\/view\/photo\/[^/]+\/public\//, `/view/photo/${size}/public/`); 138 } 139 export function resolveDoubanPhotoAssetUrl(candidates, baseUrl = '') { 140 for (const candidate of candidates) { 141 const normalized = String(candidate || '').trim(); 142 if (!normalized) 143 continue; 144 let resolved = normalized; 145 try { 146 resolved = baseUrl 147 ? new URL(normalized, baseUrl).toString() 148 : new URL(normalized).toString(); 149 } 150 catch { 151 resolved = normalized; 152 } 153 if (/^https?:\/\//i.test(resolved)) { 154 return resolved; 155 } 156 } 157 return ''; 158 } 159 export function getDoubanPhotoExtension(url) { 160 const normalized = String(url || '').trim(); 161 if (!normalized) 162 return '.jpg'; 163 try { 164 const ext = new URL(normalized).pathname.match(/\.(jpe?g|png|gif|webp|avif|bmp)$/i)?.[0]; 165 return ext || '.jpg'; 166 } 167 catch { 168 const ext = normalized.match(/\.(jpe?g|png|gif|webp|avif|bmp)(?:$|[?#])/i)?.[0]; 169 return ext ? ext.replace(/[?#].*$/, '') : '.jpg'; 170 } 171 } 172 export function normalizeDoubanBookSubject(raw) { 173 const info = parseDoubanBookInfoText(raw?.infoText); 174 const title = firstNonEmpty([raw?.title]); 175 const subtitle = firstNonEmpty([raw?.subtitle, info['副标题']]); 176 const originalTitle = firstNonEmpty([raw?.originalTitle, info['原作名']]); 177 const authors = splitDoubanPeople(firstNonEmpty([info['作者']])); 178 const translators = splitDoubanPeople(firstNonEmpty([info['译者']])); 179 const publisher = firstNonEmpty([info['出版社'], info['出品方']]); 180 const publishDate = firstNonEmpty([info['出版年']]); 181 const publishYear = extractDoubanPublishYear(publishDate); 182 const pageCount = parseDoubanPageCount(info['页数']); 183 const binding = firstNonEmpty([info['装帧']]); 184 const price = firstNonEmpty([info['定价']]); 185 const series = firstNonEmpty([info['丛书']]); 186 const isbnRaw = firstNonEmpty([info['ISBN']]).replace(/[^\dxX]/g, ''); 187 const isbn10 = isbnRaw.length === 10 ? isbnRaw : ''; 188 const isbn13 = isbnRaw.length === 13 ? isbnRaw : ''; 189 return { 190 id: normalizeDoubanSubjectId(raw?.id), 191 type: 'book', 192 title, 193 subtitle, 194 originalTitle, 195 authors, 196 translators, 197 publisher, 198 publishDate, 199 publishYear, 200 pageCount, 201 binding, 202 price, 203 series, 204 isbn10, 205 isbn13, 206 rating: parseDoubanRating(raw?.rating), 207 ratingCount: parseDoubanCount(raw?.ratingCount), 208 summary: normalizeText(raw?.summary), 209 cover: firstNonEmpty([raw?.cover]), 210 url: firstNonEmpty([raw?.url]), 211 }; 212 } 213 async function loadDoubanMovieSubject(page, subjectId) { 214 const normalizedId = normalizeDoubanSubjectId(subjectId); 215 const data = await withDetachedRetry(async () => { 216 await page.goto(`https://movie.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 }); 217 await ensureDoubanReady(page); 218 await page.wait({ selector: 'span[property="v:itemreviewed"], #info', timeout: 8 }).catch(() => { }); 219 return page.evaluate(` 220 (() => { 221 const id = ${JSON.stringify(normalizedId)}; 222 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 223 const { title, originalTitle } = (${splitDoubanTitle.toString()})(normalize(document.querySelector('span[property="v:itemreviewed"]')?.textContent || '')); 224 const year = normalize(document.querySelector('.year')?.textContent).replace(/[()()]/g, ''); 225 const rating = parseFloat(normalize(document.querySelector('strong[property="v:average"]')?.textContent || '0')) || 0; 226 const ratingCount = parseInt(normalize(document.querySelector('span[property="v:votes"]')?.textContent || '0'), 10) || 0; 227 const genres = Array.from(document.querySelectorAll('span[property="v:genre"]')) 228 .map((node) => normalize(node.textContent)) 229 .filter(Boolean) 230 .join(','); 231 const directors = Array.from(document.querySelectorAll('a[rel="v:directedBy"]')) 232 .map((node) => normalize(node.textContent)) 233 .filter(Boolean) 234 .join(','); 235 const casts = Array.from(document.querySelectorAll('a[rel="v:starring"]')) 236 .slice(0, 5) 237 .map((node) => normalize(node.textContent)) 238 .filter(Boolean); 239 const infoText = document.querySelector('#info')?.textContent || ''; 240 let country = []; 241 const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/); 242 if (countryMatch) { 243 country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean); 244 } 245 const durationRaw = normalize(document.querySelector('span[property="v:runtime"]')?.textContent || ''); 246 const durationMatch = durationRaw.match(/(\\d+)/); 247 const summary = normalize(document.querySelector('span[property="v:summary"]')?.textContent || ''); 248 return { 249 id, 250 type: 'movie', 251 title, 252 originalTitle, 253 year, 254 rating, 255 ratingCount, 256 genres, 257 directors, 258 casts, 259 country, 260 duration: durationMatch ? parseInt(durationMatch[1], 10) : null, 261 summary: summary.slice(0, 200), 262 url: 'https://movie.douban.com/subject/' + id + '/', 263 }; 264 })() 265 `); 266 }); 267 return data; 268 } 269 async function loadDoubanBookSubject(page, subjectId) { 270 const normalizedId = normalizeDoubanSubjectId(subjectId); 271 const data = await withDetachedRetry(async () => { 272 await page.goto(`https://book.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 }); 273 await ensureDoubanReady(page); 274 await page.wait({ selector: 'h1 span, #info', timeout: 8 }).catch(() => { }); 275 return page.evaluate(` 276 (() => { 277 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 278 const pickSummary = () => { 279 const nodes = Array.from(document.querySelectorAll('#link-report .intro, .related_info .intro')); 280 for (let i = nodes.length - 1; i >= 0; i -= 1) { 281 const text = normalize(nodes[i]?.textContent); 282 if (text) return text; 283 } 284 return ''; 285 }; 286 return { 287 id: ${JSON.stringify(normalizedId)}, 288 title: normalize(document.querySelector('h1 span')?.textContent || document.querySelector('h1')?.textContent || ''), 289 subtitle: '', 290 originalTitle: '', 291 infoText: document.querySelector('#info')?.innerText || document.querySelector('#info')?.textContent || '', 292 rating: normalize(document.querySelector('strong.rating_num, strong[property="v:average"]')?.textContent || ''), 293 ratingCount: normalize(document.querySelector('a.rating_people > span, span[property="v:votes"]')?.textContent || ''), 294 summary: pickSummary(), 295 cover: document.querySelector('#mainpic img')?.getAttribute('src') || '', 296 url: location.href, 297 }; 298 })() 299 `); 300 }); 301 return normalizeDoubanBookSubject(data); 302 } 303 export async function loadDoubanSubjectDetail(page, subjectId, subjectType = 'movie') { 304 const type = String(subjectType || 'movie').trim() === 'book' ? 'book' : 'movie'; 305 if (type === 'book') { 306 return loadDoubanBookSubject(page, subjectId); 307 } 308 return loadDoubanMovieSubject(page, subjectId); 309 } 310 export async function loadDoubanSubjectPhotos(page, subjectId, options = {}) { 311 const normalizedId = normalizeDoubanSubjectId(subjectId); 312 const type = String(options.type || 'Rb').trim() || 'Rb'; 313 const targetPhotoId = String(options.targetPhotoId || '').trim(); 314 const safeLimit = targetPhotoId ? Number.MAX_SAFE_INTEGER : clampPhotoLimit(Number(options.limit) || 120); 315 const resolvePhotoAssetUrlSource = resolveDoubanPhotoAssetUrl.toString(); 316 const galleryUrl = `https://movie.douban.com/subject/${normalizedId}/photos?type=${encodeURIComponent(type)}`; 317 await page.goto(galleryUrl); 318 await page.wait(2); 319 await ensureDoubanReady(page); 320 const data = await page.evaluate(` 321 (async () => { 322 const subjectId = ${JSON.stringify(normalizedId)}; 323 const type = ${JSON.stringify(type)}; 324 const limit = ${safeLimit}; 325 const targetPhotoId = ${JSON.stringify(targetPhotoId)}; 326 const pageSize = ${DOUBAN_PHOTO_PAGE_SIZE}; 327 const resolveDoubanPhotoAssetUrl = ${resolvePhotoAssetUrlSource}; 328 329 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 330 const toAbsoluteUrl = (value) => { 331 if (!value) return ''; 332 try { 333 return new URL(value, location.origin).toString(); 334 } catch { 335 return value; 336 } 337 }; 338 const promotePhotoUrl = (value) => { 339 const absolute = toAbsoluteUrl(value); 340 if (!absolute) return ''; 341 if (/^[a-z]+:/i.test(absolute) && !/^https?:/i.test(absolute)) return ''; 342 return absolute.replace(/\\/view\\/photo\\/[^/]+\\/public\\//, '/view/photo/l/public/'); 343 }; 344 const buildPageUrl = (start) => { 345 const url = new URL(location.href); 346 url.searchParams.set('type', type); 347 if (start > 0) url.searchParams.set('start', String(start)); 348 else url.searchParams.delete('start'); 349 return url.toString(); 350 }; 351 const getTitle = (doc) => { 352 const raw = normalize(doc.querySelector('#content h1')?.textContent) 353 || normalize(doc.querySelector('title')?.textContent); 354 return raw.replace(/\\s*\\(豆瓣\\)\\s*$/, ''); 355 }; 356 const extractPhotos = (doc, pageNumber) => { 357 const nodes = Array.from(doc.querySelectorAll('.poster-col3 li, .poster-col3l li, .article li')); 358 const rows = []; 359 for (const node of nodes) { 360 const link = node.querySelector('a[href*="/photos/photo/"]'); 361 const img = node.querySelector('img'); 362 if (!link || !img) continue; 363 364 const detailUrl = toAbsoluteUrl(link.getAttribute('href') || ''); 365 const photoId = detailUrl.match(/\\/photo\\/(\\d+)/)?.[1] || ''; 366 const thumbUrl = resolveDoubanPhotoAssetUrl([ 367 img.getAttribute('data-origin'), 368 img.getAttribute('data-src'), 369 img.getAttribute('src'), 370 ], location.href); 371 const imageUrl = promotePhotoUrl(thumbUrl); 372 const title = normalize(link.getAttribute('title')) 373 || normalize(img.getAttribute('alt')) 374 || (photoId ? 'photo_' + photoId : 'photo_' + String(rows.length + 1)); 375 376 if (!detailUrl || !thumbUrl || !imageUrl) continue; 377 378 rows.push({ 379 photoId, 380 title, 381 imageUrl, 382 thumbUrl, 383 detailUrl, 384 page: pageNumber, 385 }); 386 } 387 return rows; 388 }; 389 390 const subjectTitle = getTitle(document); 391 const seen = new Set(); 392 const photos = []; 393 394 for (let pageIndex = 0; photos.length < limit; pageIndex += 1) { 395 let doc = document; 396 if (pageIndex > 0) { 397 const response = await fetch(buildPageUrl(pageIndex * pageSize), { credentials: 'include' }); 398 if (!response.ok) break; 399 const html = await response.text(); 400 doc = new DOMParser().parseFromString(html, 'text/html'); 401 } 402 403 const pagePhotos = extractPhotos(doc, pageIndex + 1); 404 if (!pagePhotos.length) break; 405 406 let appended = 0; 407 let foundTarget = false; 408 for (const photo of pagePhotos) { 409 const key = photo.photoId || photo.detailUrl || photo.imageUrl; 410 if (seen.has(key)) continue; 411 seen.add(key); 412 photos.push({ 413 index: photos.length + 1, 414 ...photo, 415 }); 416 appended += 1; 417 if (targetPhotoId && photo.photoId === targetPhotoId) { 418 foundTarget = true; 419 break; 420 } 421 if (photos.length >= limit) break; 422 } 423 424 if (foundTarget || pagePhotos.length < pageSize || appended === 0) break; 425 } 426 427 return { 428 subjectId, 429 subjectTitle, 430 type, 431 photos, 432 }; 433 })() 434 `); 435 const photos = Array.isArray(data?.photos) ? data.photos : []; 436 if (!photos.length) { 437 throw new EmptyResultError('douban photos', 'No photos found. Try a different subject ID or a different --type value such as Rb.'); 438 } 439 return { 440 subjectId: normalizedId, 441 subjectTitle: String(data?.subjectTitle || '').trim(), 442 type, 443 photos, 444 }; 445 } 446 export async function loadDoubanBookHot(page, limit) { 447 const safeLimit = clampLimit(limit); 448 await page.goto('https://book.douban.com/chart'); 449 await page.wait(4); 450 await ensureDoubanReady(page); 451 const data = await page.evaluate(` 452 (() => { 453 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 454 const books = []; 455 for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) { 456 try { 457 const titleEl = el.querySelector('h2 a[href*="/subject/"]'); 458 const title = normalize(titleEl?.textContent); 459 let url = titleEl?.getAttribute('href') || ''; 460 if (!title || !url) continue; 461 if (!url.startsWith('http')) url = 'https://book.douban.com' + url; 462 463 const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent); 464 const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean); 465 const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent); 466 const quote = Array.from(el.querySelectorAll('.subject-tags .tag')) 467 .map((node) => normalize(node.textContent)) 468 .filter(Boolean) 469 .join(' / '); 470 471 books.push({ 472 rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1, 473 title, 474 rating: parseFloat(ratingText) || 0, 475 quote, 476 author: infoParts[0] || '', 477 publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '', 478 year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '', 479 price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '', 480 url, 481 cover: el.querySelector('img')?.getAttribute('src') || '', 482 }); 483 } catch {} 484 } 485 return books.slice(0, ${safeLimit}); 486 })() 487 `); 488 return Array.isArray(data) ? data : []; 489 } 490 export async function loadDoubanMovieHot(page, limit) { 491 const safeLimit = clampLimit(limit); 492 await page.goto('https://movie.douban.com/chart'); 493 await page.wait(4); 494 await ensureDoubanReady(page); 495 const data = await page.evaluate(` 496 (() => { 497 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 498 const results = []; 499 for (const el of Array.from(document.querySelectorAll('.item'))) { 500 const titleEl = el.querySelector('.pl2 a'); 501 const title = normalize(titleEl?.textContent); 502 let url = titleEl?.getAttribute('href') || ''; 503 if (!title || !url) continue; 504 if (!url.startsWith('http')) url = 'https://movie.douban.com' + url; 505 506 const info = normalize(el.querySelector('.pl2 p')?.textContent); 507 const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean); 508 const releaseIndex = (() => { 509 for (let i = infoParts.length - 1; i >= 0; i -= 1) { 510 if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i; 511 } 512 return -1; 513 })(); 514 const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : ''; 515 const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : ''; 516 const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/); 517 results.push({ 518 rank: results.length + 1, 519 title, 520 rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0, 521 quote: normalize(el.querySelector('.inq')?.textContent), 522 director: directorPart.replace(/^导演:\\s*/, ''), 523 year: yearMatch?.[0] || '', 524 region: regionPart, 525 url, 526 cover: el.querySelector('img')?.getAttribute('src') || '', 527 }); 528 if (results.length >= ${safeLimit}) break; 529 } 530 return results; 531 })() 532 `); 533 return Array.isArray(data) ? data : []; 534 } 535 export function inferDoubanSearchResultType(searchType, item = {}) { 536 const fallbackType = String(searchType || '').trim() || 'movie'; 537 if (fallbackType !== 'movie') { 538 return fallbackType; 539 } 540 const moreUrl = String(item.moreUrl || item.more_url || '').trim(); 541 const isTv = moreUrl.match(/is_tv:\s*['"]?([01])['"]?/)?.[1] || ''; 542 if (isTv === '1') { 543 return 'tvshow'; 544 } 545 const labels = Array.isArray(item.labels) 546 ? item.labels 547 .map((label) => typeof label === 'string' ? label.trim() : String(label?.text || '').trim()) 548 .filter(Boolean) 549 : []; 550 return labels.includes('剧集') ? 'tvshow' : fallbackType; 551 } 552 export async function searchDouban(page, type, keyword, limit) { 553 const safeLimit = clampLimit(limit); 554 const inferDoubanSearchResultTypeSource = inferDoubanSearchResultType.toString(); 555 const searchUrl = buildDoubanSearchUrl(type, keyword); 556 const data = await withDetachedRetry(async () => { 557 await page.goto(searchUrl, { waitUntil: 'load', settleMs: 1500 }); 558 await ensureDoubanReady(page); 559 await page.wait({ selector: DOUBAN_SEARCH_READY_SELECTOR, timeout: 8 }).catch(() => { }); 560 return page.evaluate(` 561 (async () => { 562 const type = ${JSON.stringify(type)}; 563 const inferDoubanSearchResultType = ${inferDoubanSearchResultTypeSource}; 564 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 565 const seen = new Set(); 566 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 567 const rawItems = Array.isArray(window.__DATA__?.items) ? window.__DATA__.items : []; 568 const rawItemsById = new Map( 569 rawItems 570 .map((item) => [String(item?.id || '').trim(), item]) 571 .filter(([id]) => id), 572 ); 573 574 for (let i = 0; i < 20; i += 1) { 575 if (document.querySelector('.item-root .title-text, .item-root .title a')) break; 576 await sleep(300); 577 } 578 579 const items = Array.from(document.querySelectorAll('.item-root, .result-list .result-item')); 580 581 const results = []; 582 for (const el of items) { 583 const titleEl = el.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]'); 584 const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title')); 585 let url = titleEl?.getAttribute('href') || el.querySelector('a[href*="/subject/"]')?.getAttribute('href') || ''; 586 if (!title || !url) continue; 587 if (!url.startsWith('http')) url = 'https://search.douban.com' + url; 588 if (!url.includes('/subject/') || seen.has(url)) continue; 589 seen.add(url); 590 const id = url.match(/subject\\/(\\d+)/)?.[1] || ''; 591 const rawItem = rawItemsById.get(id) || {}; 592 const ratingText = normalize(el.querySelector('.rating_nums')?.textContent); 593 const abstract = normalize( 594 el.querySelector('.meta.abstract, .meta, .abstract, .subject-abstract, p')?.textContent, 595 ); 596 results.push({ 597 rank: results.length + 1, 598 id, 599 type: inferDoubanSearchResultType(type, rawItem), 600 title, 601 rating: ratingText.includes('.') ? parseFloat(ratingText) : 0, 602 abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''), 603 url, 604 cover: el.querySelector('img')?.getAttribute('src') || '', 605 }); 606 if (results.length >= ${safeLimit}) break; 607 } 608 return results; 609 })() 610 `); 611 }); 612 return Array.isArray(data) ? data : []; 613 } 614 /** 615 * Get current user's Douban ID from movie.douban.com/mine page 616 */ 617 export async function getSelfUid(page) { 618 await page.goto('https://movie.douban.com/mine'); 619 await page.wait({ time: 2 }); 620 const uid = await page.evaluate(` 621 (() => { 622 // 方案1: 尝试从全局变量获取 623 if (window.__DATA__ && window.__DATA__.uid) { 624 return window.__DATA__.uid; 625 } 626 627 // 方案2: 从导航栏用户链接获取 628 const navUserLink = document.querySelector('.nav-user-account a'); 629 if (navUserLink) { 630 const href = navUserLink.href || ''; 631 const match = href.match(/people\\/([^/]+)/); 632 if (match) return match[1]; 633 } 634 635 // 方案3: 从页面中的个人主页链接获取 636 const profileLink = document.querySelector('a[href*="/people/"]'); 637 if (profileLink) { 638 const href = profileLink.getAttribute('href') || profileLink.href || ''; 639 const match = href.match(/people\\/([^/]+)/); 640 if (match) return match[1]; 641 } 642 643 // 方案4: 从头部用户名区域获取 644 const userLink = document.querySelector('.global-nav-items a[href*="/people/"]'); 645 if (userLink) { 646 const href = userLink.getAttribute('href') || userLink.href || ''; 647 const match = href.match(/people\\/([^/]+)/); 648 if (match) return match[1]; 649 } 650 651 return ''; 652 })() 653 `); 654 if (!uid) { 655 throw new Error('Not logged in to Douban. Please login in Chrome first.'); 656 } 657 return uid; 658 }