/ clis / douban / utils.js
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  }