diff --git a/clis/scys/activity.js b/clis/scys/activity.js new file mode 100644 index 000000000..f9f608397 --- /dev/null +++ b/clis/scys/activity.js @@ -0,0 +1,20 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractScysActivity } from './extractors.js'; +cli({ + site: 'scys', + name: 'activity', + description: 'Extract SCYS activity landing page structure (tabs, stages, tasks)', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'url', required: true, positional: true, help: 'Activity landing URL: /activity/landing/:id' }, + { name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' }, + ], + columns: ['title', 'subtitle', 'tabs', 'stages', 'url'], + func: async (page, kwargs) => { + return extractScysActivity(page, String(kwargs.url), { + waitSeconds: Number(kwargs.wait ?? 3), + }); + }, +}); diff --git a/clis/scys/article.js b/clis/scys/article.js new file mode 100644 index 000000000..f2a599bc1 --- /dev/null +++ b/clis/scys/article.js @@ -0,0 +1,48 @@ +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractScysArticle } from './extractors.js'; + +function isRetryableScysArticleError(error) { + const message = error instanceof Error ? error.message : String(error); + return error instanceof EmptyResultError + || /stale page identity/i.test(message) + || /Page not found:/i.test(message) + || /Article detail page did not hydrate beyond shell content/i.test(message); +} +cli({ + site: 'scys', + name: 'article', + description: 'Extract SCYS article detail page content and metadata', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'url', required: true, positional: true, help: 'Article URL or topic id: /articleDetail//' }, + { name: 'wait', type: 'int', default: 5, help: 'Seconds to wait after page load' }, + { name: 'max-length', type: 'int', default: 4000, help: 'Max content length for long text fields' }, + ], + columns: ['topic_id', 'entity_type', 'title', 'author', 'time', 'tags', 'flags', 'image_count', 'external_link_count', 'content', 'ai_summary', 'url'], + func: async (page, kwargs) => { + const url = String(kwargs.url); + const options = { + waitSeconds: Number(kwargs.wait ?? 5), + maxLength: Number(kwargs['max-length'] ?? 4000), + }; + let lastError = null; + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await extractScysArticle(page, url, options); + } catch (error) { + lastError = error; + if (!isRetryableScysArticleError(error) || attempt === maxAttempts) { + throw error; + } + // A full window reset is closer to the successful manual re-run path + // than another probe inside the same browser state. + await page.closeWindow?.().catch(() => { }); + } + } + throw lastError; + }, +}); diff --git a/clis/scys/article.test.js b/clis/scys/article.test.js new file mode 100644 index 000000000..83b2080f6 --- /dev/null +++ b/clis/scys/article.test.js @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EmptyResultError } from '@jackwener/opencli/errors'; + +const { mockExtractScysArticle } = vi.hoisted(() => ({ + mockExtractScysArticle: vi.fn(), +})); + +vi.mock('./extractors.js', () => ({ + extractScysArticle: mockExtractScysArticle, +})); + +import { getRegistry } from '@jackwener/opencli/registry'; +import './article.js'; + +describe('scys article command retry', () => { + const command = getRegistry().get('scys/article'); + const page = { + closeWindow: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + mockExtractScysArticle.mockReset(); + page.closeWindow.mockClear(); + }); + + it('retries once after shell-only EmptyResultError', async () => { + mockExtractScysArticle + .mockRejectedValueOnce(new EmptyResultError('scys/article', 'Article detail page did not hydrate beyond shell content')) + .mockResolvedValueOnce({ topic_id: '14422288551185512', title: 'ok' }); + + const result = await command.func(page, { + url: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + wait: 6, + 'max-length': 4000, + }); + + expect(result).toEqual({ topic_id: '14422288551185512', title: 'ok' }); + expect(mockExtractScysArticle).toHaveBeenCalledTimes(2); + expect(page.closeWindow).toHaveBeenCalledTimes(1); + }); + + it('retries up to three attempts for retryable shell-only errors', async () => { + mockExtractScysArticle + .mockRejectedValueOnce(new EmptyResultError('scys/article', 'Article detail page did not hydrate beyond shell content')) + .mockRejectedValueOnce(new EmptyResultError('scys/article', 'Article detail page did not hydrate beyond shell content')) + .mockResolvedValueOnce({ topic_id: '14422288551185512', title: 'ok' }); + + const result = await command.func(page, { + url: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + wait: 6, + 'max-length': 4000, + }); + + expect(result).toEqual({ topic_id: '14422288551185512', title: 'ok' }); + expect(mockExtractScysArticle).toHaveBeenCalledTimes(3); + expect(page.closeWindow).toHaveBeenCalledTimes(2); + }); + + it('does not retry non-retryable errors', async () => { + mockExtractScysArticle.mockRejectedValueOnce(new Error('boom')); + + await expect(command.func(page, { + url: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + wait: 6, + 'max-length': 4000, + })).rejects.toThrow('boom'); + + expect(mockExtractScysArticle).toHaveBeenCalledTimes(1); + expect(page.closeWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/scys/common.js b/clis/scys/common.js new file mode 100644 index 000000000..89bdea518 --- /dev/null +++ b/clis/scys/common.js @@ -0,0 +1,99 @@ +import { ArgumentError } from '@jackwener/opencli/errors'; +const SCYS_ORIGIN = 'https://scys.com'; +export function normalizeScysUrl(input) { + const raw = String(input ?? '').trim(); + if (!raw) { + throw new ArgumentError('SCYS URL is required'); + } + if (/^https?:\/\//i.test(raw)) { + return raw; + } + if (raw.startsWith('/')) { + return `${SCYS_ORIGIN}${raw}`; + } + if (raw.startsWith('scys.com')) { + return `https://${raw}`; + } + return `${SCYS_ORIGIN}/${raw.replace(/^\/+/, '')}`; +} +export function toScysCourseUrl(input) { + const raw = String(input ?? '').trim(); + if (!raw) + throw new ArgumentError('Course URL or course id is required'); + if (/^\d+$/.test(raw)) { + return `${SCYS_ORIGIN}/course/detail/${raw}`; + } + return normalizeScysUrl(raw); +} +export function toScysArticleUrl(input) { + const raw = String(input ?? '').trim(); + if (!raw) + throw new ArgumentError('Article URL is required'); + if (/^\d{8,}$/.test(raw)) { + return `${SCYS_ORIGIN}/articleDetail/xq_topic/${raw}`; + } + const url = normalizeScysUrl(raw); + const parsed = new URL(url); + const match = parsed.pathname.match(/^\/articleDetail\/([^/]+)\/([^/]+)$/); + if (!match) { + throw new ArgumentError(`Unsupported SCYS article URL: ${input}`, 'Use /articleDetail// or pass a numeric topic id'); + } + return url; +} +export function detectScysPageType(input) { + const url = new URL(normalizeScysUrl(input)); + const pathname = url.pathname; + if (pathname.startsWith('/course/detail/')) + return 'course'; + if (pathname.startsWith('/opportunity')) + return 'opportunity'; + if (pathname.startsWith('/activity/landing/')) + return 'activity'; + if (/^\/articleDetail\/[^/]+\/[^/]+$/.test(pathname)) + return 'article'; + if (pathname.startsWith('/personal/')) { + const tab = (url.searchParams.get('tab') || '').toLowerCase(); + if (tab === 'posts') + return 'feed'; + } + if (pathname === '/' || pathname === '') { + const filter = (url.searchParams.get('filter') || '').toLowerCase(); + if (filter === 'essence') + return 'feed'; + } + return 'unknown'; +} +export function extractScysCourseId(input) { + const url = new URL(toScysCourseUrl(input)); + const match = url.pathname.match(/\/course\/detail\/(\d+)/); + return match?.[1] ?? ''; +} +export function extractScysArticleMeta(input) { + const url = new URL(toScysArticleUrl(input)); + const match = url.pathname.match(/^\/articleDetail\/([^/]+)\/([^/]+)$/); + return { + entityType: match?.[1] ?? '', + topicId: match?.[2] ?? '', + }; +} +export function cleanText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} +export function extractInteractions(raw) { + const text = cleanText(raw); + if (!text) + return ''; + const pieces = text.match(/[0-9]+(?:\.[0-9]+)?(?:万|亿)?/g); + if (!pieces || pieces.length === 0) + return text; + return pieces.join(' '); +} +export function inferScysReadUrl(input) { + return normalizeScysUrl(input); +} +export function buildScysHomeEssenceUrl() { + return `${SCYS_ORIGIN}/?filter=essence`; +} +export function buildScysOpportunityUrl() { + return `${SCYS_ORIGIN}/opportunity`; +} diff --git a/clis/scys/common.test.js b/clis/scys/common.test.js new file mode 100644 index 000000000..75f3efec6 --- /dev/null +++ b/clis/scys/common.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { cleanText, detectScysPageType, extractScysArticleMeta, extractInteractions, normalizeScysUrl, toScysArticleUrl, toScysCourseUrl, } from './common.js'; +describe('normalizeScysUrl', () => { + it('normalizes bare domain and keeps path/query', () => { + expect(normalizeScysUrl('scys.com/course/detail/142?chapterId=9445')).toBe('https://scys.com/course/detail/142?chapterId=9445'); + }); + it('normalizes root-relative paths', () => { + expect(normalizeScysUrl('/opportunity')).toBe('https://scys.com/opportunity'); + }); +}); +describe('toScysCourseUrl', () => { + it('accepts numeric course id', () => { + expect(toScysCourseUrl('92')).toBe('https://scys.com/course/detail/92'); + }); + it('keeps full course detail URL unchanged', () => { + expect(toScysCourseUrl('https://scys.com/course/detail/142?chapterId=9445')).toBe('https://scys.com/course/detail/142?chapterId=9445'); + }); +}); +describe('toScysArticleUrl', () => { + it('accepts numeric topic id', () => { + expect(toScysArticleUrl('55188458224514554')).toBe('https://scys.com/articleDetail/xq_topic/55188458224514554'); + }); + it('keeps full article detail url', () => { + expect(toScysArticleUrl('https://scys.com/articleDetail/xq_topic/55188458224514554')).toBe('https://scys.com/articleDetail/xq_topic/55188458224514554'); + }); +}); +describe('extractScysArticleMeta', () => { + it('extracts entity type and topic id from url', () => { + expect(extractScysArticleMeta('https://scys.com/articleDetail/xq_topic/55188458224514554')).toEqual({ + entityType: 'xq_topic', + topicId: '55188458224514554', + }); + }); +}); +describe('detectScysPageType', () => { + it('detects course detail with chapterId', () => { + expect(detectScysPageType('https://scys.com/course/detail/142?chapterId=9445')).toBe('course'); + }); + it('detects course detail without chapterId', () => { + expect(detectScysPageType('https://scys.com/course/detail/92')).toBe('course'); + }); + it('detects essence feed on homepage', () => { + expect(detectScysPageType('https://scys.com/?filter=essence')).toBe('feed'); + }); + it('detects profile posts feed', () => { + expect(detectScysPageType('https://scys.com/personal/421122582111848?number=18563&tab=posts')).toBe('feed'); + }); + it('detects opportunity page', () => { + expect(detectScysPageType('https://scys.com/opportunity')).toBe('opportunity'); + }); + it('detects activity landing page', () => { + expect(detectScysPageType('https://scys.com/activity/landing/5505?tabIndex=1')).toBe('activity'); + }); + it('detects article detail page', () => { + expect(detectScysPageType('https://scys.com/articleDetail/xq_topic/55188458224514554')).toBe('article'); + }); + it('returns unknown for unsupported pages', () => { + expect(detectScysPageType('https://scys.com/help')).toBe('unknown'); + }); +}); +describe('text helpers', () => { + it('cleanText collapses whitespace', () => { + expect(cleanText(' hello\n\nworld ')).toBe('hello world'); + }); + it('extractInteractions keeps compact numeric text', () => { + expect(extractInteractions('赞 1.2万 评论 35')).toBe('1.2万 35'); + }); +}); diff --git a/clis/scys/course-download.js b/clis/scys/course-download.js new file mode 100644 index 000000000..68e0b5e90 --- /dev/null +++ b/clis/scys/course-download.js @@ -0,0 +1,104 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { formatCookieHeader, httpDownload } from '@jackwener/opencli/download'; +function sanitizeExtname(url) { + try { + const pathname = new URL(url).pathname || ''; + const ext = path.extname(pathname).toLowerCase(); + if (ext && ext.length <= 6) + return ext; + } + catch { + // ignore invalid URL and fall back + } + return '.jpg'; +} +function hashUrl(url) { + return createHash('sha1').update(url).digest('hex'); +} +function buildDownloadPlan(rows, output) { + const cacheDir = path.join(output, '.cache'); + const byUrl = new Map(); + rows.forEach((row, rowIndex) => { + const courseId = row.course_id || 'course'; + const chapterId = row.chapter_id || 'root'; + const imageUrls = Array.isArray(row.images) ? row.images.filter(Boolean) : []; + imageUrls.forEach((url, imageIndex) => { + const ext = sanitizeExtname(url); + const cachePath = path.join(cacheDir, `${hashUrl(url)}${ext}`); + const destPath = path.join(output, courseId, chapterId, `${courseId}_${chapterId}_${imageIndex + 1}${ext}`); + const existing = byUrl.get(url); + if (existing) { + existing.copies.push({ rowIndex, destPath }); + return; + } + byUrl.set(url, { + url, + cachePath, + copies: [{ rowIndex, destPath }], + }); + }); + }); + return Array.from(byUrl.values()); +} +async function runWithConcurrency(items, concurrency, worker) { + const limit = Math.max(1, Math.floor(concurrency)); + let cursor = 0; + async function consume() { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + await worker(items[index]); + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())); +} +function createDefaultDeps() { + return { + concurrency: 8, + downloadToPath: async (url, destPath, cookies) => { + const result = await httpDownload(url, destPath, { + cookies, + timeout: 60_000, + }); + return result.success; + }, + }; +} +export async function downloadScysCourseImagesInternal(data, output, cookies, overrides = {}) { + const rows = Array.isArray(data) ? data : [data]; + const deps = { ...createDefaultDeps(), ...overrides }; + const withDownloads = rows.map((row) => ({ ...row, image_count: 0, image_dir: '' })); + const plan = buildDownloadPlan(withDownloads, output); + const successCounts = new Array(withDownloads.length).fill(0); + await fs.promises.mkdir(path.join(output, '.cache'), { recursive: true }); + await runWithConcurrency(plan, deps.concurrency, async (entry) => { + let available = false; + try { + await fs.promises.access(entry.cachePath, fs.constants.F_OK); + available = true; + } + catch { + await fs.promises.mkdir(path.dirname(entry.cachePath), { recursive: true }); + available = await deps.downloadToPath(entry.url, entry.cachePath, cookies); + } + if (!available) + return; + await Promise.all(entry.copies.map(async (copy) => { + await fs.promises.mkdir(path.dirname(copy.destPath), { recursive: true }); + await fs.promises.copyFile(entry.cachePath, copy.destPath); + successCounts[copy.rowIndex] += 1; + })); + }); + const result = withDownloads.map((row, index) => ({ + ...row, + image_count: successCounts[index] ?? 0, + image_dir: row.images.length > 0 ? path.join(output, row.course_id || 'course', row.chapter_id || 'root') : '', + })); + return Array.isArray(data) ? result : result[0]; +} +export async function downloadScysCourseImages(page, data, output) { + const cookies = formatCookieHeader(await page.getCookies({ domain: 'scys.com' })); + return downloadScysCourseImagesInternal(data, output, cookies); +} diff --git a/clis/scys/course-download.test.js b/clis/scys/course-download.test.js new file mode 100644 index 000000000..462826d54 --- /dev/null +++ b/clis/scys/course-download.test.js @@ -0,0 +1,81 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { downloadScysCourseImagesInternal } from './course-download.js'; +function makeRow(overrides) { + return { + course_title: 'Course', + chapter_title: 'Chapter', + breadcrumb: 'A > B > C', + content: 'body', + chapter_id: '1', + course_id: '92', + toc_summary: '', + url: 'https://scys.com/course/detail/92?chapterId=1', + raw_url: 'https://scys.com/course/detail/92?chapterId=1', + updated_at_text: '', + copyright_text: '', + prev_chapter: '', + next_chapter: '', + participant_count: 0, + discussion_hint: '', + links: [], + images: [], + image_count: 0, + content_images: [], + content_image_count: 0, + image_dir: '', + ...overrides, + }; +} +describe('downloadScysCourseImagesInternal', () => { + it('deduplicates repeated image urls across chapters and copies cached files', async () => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), 'scys-course-download-')); + const rows = [ + makeRow({ chapter_id: '4038', images: ['https://cdn.example.com/shared.png', 'https://cdn.example.com/unique-a.png'] }), + makeRow({ chapter_id: '4039', images: ['https://cdn.example.com/shared.png'] }), + ]; + const calls = []; + const result = await downloadScysCourseImagesInternal(rows, output, 'cookie=a', { + concurrency: 2, + downloadToPath: async (url, destPath) => { + calls.push(url); + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.writeFile(destPath, `downloaded:${url}`); + return true; + }, + }); + expect(calls).toEqual([ + 'https://cdn.example.com/shared.png', + 'https://cdn.example.com/unique-a.png', + ]); + expect(result[0]?.image_count).toBe(2); + expect(result[1]?.image_count).toBe(1); + expect(fs.existsSync(path.join(output, '92', '4038', '92_4038_1.png'))).toBe(true); + expect(fs.existsSync(path.join(output, '92', '4038', '92_4038_2.png'))).toBe(true); + expect(fs.existsSync(path.join(output, '92', '4039', '92_4039_1.png'))).toBe(true); + }); + it('downloads unique image urls concurrently instead of one-by-one', async () => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), 'scys-course-download-')); + const rows = [ + makeRow({ chapter_id: '4038', images: ['https://cdn.example.com/a.png', 'https://cdn.example.com/b.png'] }), + makeRow({ chapter_id: '4039', images: ['https://cdn.example.com/c.png', 'https://cdn.example.com/d.png'] }), + ]; + let active = 0; + let maxActive = 0; + await downloadScysCourseImagesInternal(rows, output, 'cookie=a', { + concurrency: 3, + downloadToPath: async (_url, destPath) => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 30)); + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.writeFile(destPath, 'x'); + active -= 1; + return true; + }, + }); + expect(maxActive).toBeGreaterThan(1); + }); +}); diff --git a/clis/scys/course-utils.js b/clis/scys/course-utils.js new file mode 100644 index 000000000..02506ae22 --- /dev/null +++ b/clis/scys/course-utils.js @@ -0,0 +1,137 @@ +import { cleanText, normalizeScysUrl, toScysCourseUrl } from './common.js'; +function safeNormalizeScysUrl(url) { + const cleaned = cleanText(url); + if (!cleaned) + return ''; + return normalizeScysUrl(cleaned); +} +function dedupeStrings(values) { + return Array.from(new Set(values.map((value) => cleanText(value)).filter(Boolean))); +} +function normalizeImageUrl(url) { + if (!url) + return ''; + if (url.startsWith('http://') || url.startsWith('https://')) + return url; + if (url.startsWith('/')) + return `https://scys.com${url}`; + return ''; +} +function normalizeImages(values) { + return Array.from(new Set(values + .map((value) => normalizeImageUrl(cleanText(value))) + .filter(Boolean) + .filter((value) => !value.startsWith('data:')) + .filter((value) => !/\/images\/pic_empty\.png$/i.test(value)))); +} +function chooseCourseChapterTitle(payload) { + const explicit = cleanText(payload.chapterTitle); + if (explicit) + return explicit; + const byToc = (payload.tocRows ?? []).find((row) => cleanText(row.chapter_id) === cleanText(payload.chapterId)); + if (byToc?.chapter_title) + return cleanText(byToc.chapter_title); + const current = cleanText(payload.currentChapter); + if (current) + return current; + const breadcrumbLast = cleanText((payload.breadcrumb ?? []).at(-1)); + return breadcrumbLast; +} +function normalizeBreadcrumb(payload, chapterTitle) { + const byToc = (payload.tocRows ?? []).find((row) => cleanText(row.chapter_id) === cleanText(payload.chapterId)); + if (byToc) { + const tocParts = [cleanText(byToc.section), cleanText(byToc.group), chapterTitle || cleanText(byToc.chapter_title)].filter(Boolean); + if (tocParts.length > 0) + return tocParts.join(' > '); + } + const parts = dedupeStrings(payload.breadcrumb ?? []); + if (parts.length === 0) + return chapterTitle; + if (!chapterTitle) + return parts.join(' > '); + return [...parts.slice(0, -1), chapterTitle].filter(Boolean).join(' > '); +} +function parseParticipantCount(input) { + const match = cleanText(input).match(/(\d+)\s*人参与/); + return match?.[1] ? Number(match[1]) : 0; +} +// Course正文是由多个内联节点拼接而成,URL 常在 DOM 文本合并时被打散。 +export function repairScysBrokenUrls(input) { + let output = cleanText(input); + if (!output) + return ''; + output = output.replace(/\b(https?)\s*:\s*\/\//gi, '$1://'); + output = output.replace(/(https?:\/\/)\s+/gi, '$1'); + let previous = ''; + while (output !== previous) { + previous = output; + output = output.replace(/(https?:\/\/[A-Za-z0-9._-]*[./])\s+([A-Za-z0-9._/-]+)/gi, '$1$2'); + output = output.replace(/(https?:\/\/[A-Za-z0-9._-]+)\s+([A-Za-z0-9._-]+\.[A-Za-z]{2,}(?:\/[A-Za-z0-9._/-]*)?)/gi, '$1$2'); + } + output = output.replace(/https?:\/\/www\.curor\.com\//gi, 'http://www.cursor.com/'); + output = output.replace(/https?:\/\/github\.com\/ignup/gi, 'http://github.com/signup'); + output = output.replace(/https?:\/\/iliconflow\.cn\//gi, 'http://siliconflow.cn/'); + return output; +} +export function summarizeScysToc(rows) { + return rows + .slice(0, 24) + .map((row, index) => { + const section = cleanText(row.section); + const group = cleanText(row.group); + const chapterTitle = cleanText(row.chapter_title); + const entryType = cleanText(row.entry_type); + const left = entryType === 'section' + ? section || chapterTitle || group + : [section, group, chapterTitle].filter(Boolean).join(' > ').replace(/ > ([^>]+)$/, '/$1'); + return `${index + 1}.${left}${row.chapter_id ? `(${cleanText(row.chapter_id)})` : ''}`; + }) + .join(' | '); +} +export function buildScysCourseChapterUrls(baseUrl, rows) { + const courseUrl = new URL(toScysCourseUrl(baseUrl)); + const seen = new Set(); + const urls = []; + for (const row of rows) { + const chapterId = cleanText(row.chapter_id); + if (!chapterId) + continue; + if (cleanText(row.entry_type) && cleanText(row.entry_type) !== 'chapter') + continue; + if (seen.has(chapterId)) + continue; + seen.add(chapterId); + const url = new URL(courseUrl.toString()); + url.searchParams.set('chapterId', chapterId); + urls.push(url.toString()); + } + return urls; +} +export function normalizeScysCoursePayload(payload) { + const chapterTitle = chooseCourseChapterTitle(payload); + const images = normalizeImages(payload.images ?? []); + const contentImages = normalizeImages(payload.contentImages ?? []); + const links = dedupeStrings(payload.links ?? []).map((value) => normalizeScysUrl(value)).filter(Boolean); + return { + course_title: cleanText(payload.courseTitle), + chapter_title: chapterTitle, + breadcrumb: normalizeBreadcrumb(payload, chapterTitle), + content: repairScysBrokenUrls(cleanText(payload.content)), + chapter_id: cleanText(payload.chapterId), + toc_summary: summarizeScysToc(payload.tocRows ?? []), + url: safeNormalizeScysUrl(payload.pageUrl || ''), + raw_url: safeNormalizeScysUrl(payload.pageUrl || ''), + updated_at_text: cleanText(payload.updatedAtText), + copyright_text: cleanText(payload.copyrightText), + prev_chapter: cleanText(payload.prevChapter), + next_chapter: cleanText(payload.nextChapter), + participant_count: parseParticipantCount(cleanText(payload.participantText)), + discussion_hint: cleanText(payload.discussionHint), + links, + images, + image_count: images.length, + content_images: contentImages, + content_image_count: contentImages.length, + image_dir: '', + }; +} diff --git a/clis/scys/course-utils.test.js b/clis/scys/course-utils.test.js new file mode 100644 index 000000000..f8768fc18 --- /dev/null +++ b/clis/scys/course-utils.test.js @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { buildScysCourseChapterUrls, normalizeScysCoursePayload, repairScysBrokenUrls, summarizeScysToc, } from './course-utils.js'; +describe('repairScysBrokenUrls', () => { + it('repairs spaced and split urls in extracted course text', () => { + const input = [ + '工具地址:http ://raphael.app', + '编辑器:http ://www.cur or.com/', + '注册页:http ://github.com/ ignup', + '平台:http :// iliconflow.cn/', + ].join(' '); + expect(repairScysBrokenUrls(input)).toContain('http://raphael.app'); + expect(repairScysBrokenUrls(input)).toContain('http://www.cursor.com/'); + expect(repairScysBrokenUrls(input)).toContain('http://github.com/signup'); + expect(repairScysBrokenUrls(input)).toContain('http://siliconflow.cn/'); + }); +}); +describe('normalizeScysCoursePayload', () => { + it('prefers the content title over a stale active chapter when chapterId is explicit', () => { + const result = normalizeScysCoursePayload({ + courseTitle: '【深海圈】AI产品出海', + chapterTitle: '课程目标', + currentChapter: '课程前言', + breadcrumb: ['预备篇', '图文', '课程前言'], + content: '课程目标:正本清源', + chapterId: '4038', + pageUrl: 'https://scys.com/course/detail/92?chapterId=4038', + images: [ + 'https://cdn.example.com/cover.jpg', + '/assets/logo.png', + 'data:image/png;base64,abc', + '/images/pic_empty.png', + ], + contentImages: ['https://cdn.example.com/content-1.jpg'], + updatedAtText: '更新于:2025.12.02 08:03', + copyrightText: '版权归生财有术及手册出品人所有', + prevChapter: '上一节 课程前言', + nextChapter: '下一节 基础篇', + participantText: '146人参与', + discussionHint: '发起讨论', + links: [' https://scys.com/course/detail/92?chapterId=4038 ', 'https://example.com/a '], + tocRows: [ + { section: '预备篇', group: '图文', chapter_id: '4137', chapter_title: '课程前言', status: '737人学过', is_current: false, rank: 1, entry_type: 'chapter' }, + { section: '预备篇', group: '图文', chapter_id: '4038', chapter_title: '课程目标', status: '508人学过', is_current: true, rank: 2, entry_type: 'chapter' }, + ], + }); + expect(result.chapter_title).toBe('课程目标'); + expect(result.breadcrumb).toBe('预备篇 > 图文 > 课程目标'); + expect(result.updated_at_text).toBe('更新于:2025.12.02 08:03'); + expect(result.participant_count).toBe(146); + expect(result.image_count).toBe(2); + expect(result.images).toEqual(['https://cdn.example.com/cover.jpg', 'https://scys.com/assets/logo.png']); + expect(result.content_image_count).toBe(1); + expect(result.links).toEqual([ + 'https://scys.com/course/detail/92?chapterId=4038', + 'https://example.com/a', + ]); + }); + it('prefers toc-based section and group when breadcrumb is polluted by sidebar state', () => { + const result = normalizeScysCoursePayload({ + courseTitle: '【深海圈】AI产品出海', + chapterTitle: '课程前言', + breadcrumb: ['问答(持续更新)', '图文', '课程前言'], + content: '课程前言正文', + chapterId: '4137', + pageUrl: 'https://scys.com/course/detail/92?chapterId=4137', + tocRows: [ + { section: '预备篇', group: '图文', chapter_id: '4137', chapter_title: '课程前言', status: '737人学过', is_current: false, rank: 1, entry_type: 'chapter' }, + ], + }); + expect(result.breadcrumb).toBe('预备篇 > 图文 > 课程前言'); + }); +}); +describe('summarizeScysToc', () => { + it('includes all visible groups and chapters in the summary', () => { + expect(summarizeScysToc([ + { rank: 1, entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4137', chapter_title: '课程前言', status: '', is_current: false }, + { rank: 2, entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4038', chapter_title: '课程目标', status: '', is_current: true }, + { rank: 3, entry_type: 'section', section: '基础篇', group: '基础篇', chapter_id: '', chapter_title: '基础篇', status: '', is_current: false }, + ])).toBe('1.预备篇 > 图文/课程前言(4137) | 2.预备篇 > 图文/课程目标(4038) | 3.基础篇'); + }); +}); +describe('buildScysCourseChapterUrls', () => { + it('builds deterministic chapter urls from toc rows', () => { + expect(buildScysCourseChapterUrls('https://scys.com/course/detail/92', [ + { rank: 1, entry_type: 'section', section: '预备篇', group: '预备篇', chapter_id: '', chapter_title: '预备篇', status: '', is_current: false }, + { rank: 2, entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4137', chapter_title: '课程前言', status: '', is_current: false }, + { rank: 3, entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4038', chapter_title: '课程目标', status: '', is_current: true }, + ])).toEqual([ + 'https://scys.com/course/detail/92?chapterId=4137', + 'https://scys.com/course/detail/92?chapterId=4038', + ]); + }); +}); diff --git a/clis/scys/course.js b/clis/scys/course.js new file mode 100644 index 000000000..3568b8829 --- /dev/null +++ b/clis/scys/course.js @@ -0,0 +1,50 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { downloadScysCourseImages } from './course-download.js'; +import { extractScysCourse, extractScysCourseAll } from './extractors.js'; +cli({ + site: 'scys', + name: 'course', + description: 'Read SCYS course detail content and chapter context', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'url', required: true, positional: true, help: 'Course URL: /course/detail/:id[?chapterId=...]' }, + { name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' }, + { name: 'max-length', type: 'int', default: 4000, help: 'Max content length' }, + { name: 'all', type: 'boolean', default: false, help: 'Export all deterministic chapter ids from TOC' }, + { name: 'download-images', type: 'boolean', default: false, help: 'Download course page images to local directory' }, + { name: 'output', default: './scys-course-downloads', help: 'Image output directory' }, + ], + columns: [ + 'course_title', + 'chapter_title', + 'breadcrumb', + 'updated_at_text', + 'participant_count', + 'image_count', + 'content_image_count', + 'prev_chapter', + 'next_chapter', + 'chapter_id', + 'course_id', + 'url', + 'image_dir', + ], + func: async (page, kwargs) => { + const all = kwargs.all === true || String(kwargs.all) === 'true'; + const data = all + ? await extractScysCourseAll(page, String(kwargs.url), { + waitSeconds: Number(kwargs.wait ?? 3), + maxLength: Number(kwargs['max-length'] ?? 4000), + }) + : await extractScysCourse(page, String(kwargs.url), { + waitSeconds: Number(kwargs.wait ?? 3), + maxLength: Number(kwargs['max-length'] ?? 4000), + }); + const downloadImages = kwargs['download-images'] === true || String(kwargs['download-images']) === 'true'; + if (!downloadImages) + return data; + return downloadScysCourseImages(page, data, String(kwargs.output ?? './scys-course-downloads')); + }, +}); diff --git a/clis/scys/extractors.js b/clis/scys/extractors.js new file mode 100644 index 000000000..ea176fa96 --- /dev/null +++ b/clis/scys/extractors.js @@ -0,0 +1,1645 @@ +import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cleanText, extractScysArticleMeta, extractScysCourseId, normalizeScysUrl, toScysArticleUrl, toScysCourseUrl, } from './common.js'; +import { buildScysTopicLink, formatScysRelativeTime, inferTopicIdFromImageUrls, normalizeOpportunityTab, parseAiSummaryText, stripScysRichText, splitOpportunityFlagsAndTags, } from './opportunity-utils.js'; +import { buildScysCourseChapterUrls, normalizeScysCoursePayload, repairScysBrokenUrls, } from './course-utils.js'; +const SCYS_DOMAIN = 'scys.com'; +const SCYS_TEXT_FIXUPS = [ + [/\bCur\s*or\b/g, 'Cursor'], + [/\bBu\s*ine\b/g, 'Business'], + [/\bJava\s*cript\b/g, 'Javascript'], + [/\bSupaba\s*e\b/g, 'Supabase'], + [/\bcreen\s*haring\b/gi, 'screensharing'], + [/\bfa\s*t3d\b/gi, 'fast3d'], +]; +const SCYS_SHELL_TITLES = new Set(['生财官网·会员主题贴']); +const SCYS_TITLE_PREFIX_PATTERN = /^(?:精华|热门|中标|信息差|新玩法|市场洞察|风向标)\s*/; +async function gotoAndWait(page, url, waitSeconds) { + await page.goto(url); + await page.wait(waitSeconds); +} +function pickPreferredScysLink(candidates) { + const links = Array.from(new Set(candidates + .map((value) => cleanText(value)) + .filter(Boolean) + .map((value) => value.replace(/\s+/g, '')))); + if (links.length === 0) + return ''; + const detail = links.find((link) => /^https?:\/\/(?:www\.)?scys\.com\/articleDetail\//i.test(link)); + if (detail) + return detail; + const internal = links.find((link) => /^https?:\/\/(?:www\.)?scys\.com\//i.test(link)); + if (internal) + return internal; + return links[0] ?? ''; +} +function getScysArticleMetaFromUrl(url) { + const normalized = cleanText(url); + if (!/^https?:\/\/(?:www\.)?scys\.com\/articleDetail\//i.test(normalized)) { + return { entityType: '', topicId: '' }; + } + try { + return extractScysArticleMeta(normalized); + } + catch { + return { entityType: '', topicId: '' }; + } +} +function normalizeScysExternalLinks(candidates) { + return Array.from(new Set((candidates ?? []) + .map((value) => normalizeMaybeBrokenUrl(value)) + .filter(isLikelyExternalLink) + .filter((href) => !isLikelyFalsePositiveLink(href)))); +} +function buildScysListLinkFields(primaryUrl, entityType, topicId, linkCandidates) { + const internalUrl = buildScysTopicLink(entityType, topicId); + const url = pickPreferredScysLink([ + internalUrl, + primaryUrl, + ...(linkCandidates ?? []), + ]); + const externalLinks = normalizeScysExternalLinks(linkCandidates); + return { + url, + raw_url: url, + source_links: externalLinks, + external_links: externalLinks, + }; +} +function normalizeScysTitleKey(value) { + return polishScysText(stripScysRichText(value ?? '')) + .replace(SCYS_TITLE_PREFIX_PATTERN, '') + .replace(/\s+/g, '') + .replace(/[“”"'‘’#::|·,。,!?!?\[\]()()【】《》<>]/g, '') + .toLowerCase(); +} +function normalizeScysCacheEntryLinks(entry) { + return [ + ...(Array.isArray(entry?.links) ? entry.links : []), + ...(Array.isArray(entry?.feishu_links) ? entry.feishu_links : []), + ...(Array.isArray(entry?.source_links) ? entry.source_links : []), + ...(Array.isArray(entry?.external_links) ? entry.external_links : []), + entry?.externalLink, + entry?.external_link, + entry?.url, + entry?.raw_url, + ]; +} +function normalizeScysPageCacheEntry(entry) { + if (!entry || typeof entry !== 'object') + return null; + const topicId = cleanText(entry.topic_id ?? entry.topicId ?? entry.entityId); + const meta = getScysArticleMetaFromUrl(entry.scys_url ?? entry.url ?? entry.raw_url ?? ''); + const entityType = cleanText(entry.entity_type ?? entry.entityType ?? meta.entityType ?? (topicId ? 'xq_topic' : '')); + const detailUrl = pickPreferredScysLink([ + entry.scys_url, + buildScysTopicLink(entityType, topicId || meta.topicId), + entry.url, + entry.raw_url, + ]); + const externalLinks = normalizeScysExternalLinks(normalizeScysCacheEntryLinks(entry)); + return { + title: polishScysText(entry.title ?? ''), + topic_id: topicId || meta.topicId, + entity_type: entityType, + url: detailUrl, + raw_url: detailUrl, + source_links: externalLinks, + external_links: externalLinks, + author: polishScysText(entry.author ?? ''), + time: polishScysText(entry.time ?? ''), + }; +} +function scoreScysCacheMatch(row, entry) { + let score = 0; + if (!entry) + return score; + if (cleanText(row.topic_id ?? '') && row.topic_id === entry.topic_id) + score += 120; + const rowExternal = normalizeScysExternalLinks([ + ...(row.external_links ?? []), + ...(row.source_links ?? []), + row.url, + row.raw_url, + ]); + if (rowExternal.some((href) => entry.external_links.includes(href))) + score += 80; + const rowUrlMeta = getScysArticleMetaFromUrl(row.url ?? row.raw_url ?? ''); + if (rowUrlMeta.topicId && rowUrlMeta.topicId === entry.topic_id) + score += 60; + const rowTitleKey = normalizeScysTitleKey(row.title ?? ''); + const entryTitleKey = normalizeScysTitleKey(entry.title ?? ''); + if (rowTitleKey && entryTitleKey) { + if (rowTitleKey === entryTitleKey) { + score += 50; + } + else if (rowTitleKey.includes(entryTitleKey) || entryTitleKey.includes(rowTitleKey)) { + score += 35; + } + } + const rowSummaryKey = normalizeScysTitleKey(row.summary ?? ''); + if (rowSummaryKey && entryTitleKey && rowSummaryKey.includes(entryTitleKey)) + score += 20; + return score; +} +function enrichScysListRows(rows, cacheEntries) { + const normalizedCache = (cacheEntries ?? []) + .map((entry) => normalizeScysPageCacheEntry(entry)) + .filter(Boolean); + if (normalizedCache.length === 0) + return rows; + return rows.map((row) => { + let best = null; + let bestScore = 0; + for (const entry of normalizedCache) { + const score = scoreScysCacheMatch(row, entry); + if (score > bestScore) { + best = entry; + bestScore = score; + } + } + const existingMeta = getScysArticleMetaFromUrl(row.url ?? row.raw_url ?? ''); + const topicId = cleanText(row.topic_id || existingMeta.topicId || best?.topic_id || ''); + const entityType = cleanText(row.entity_type || existingMeta.entityType || best?.entity_type || (topicId ? 'xq_topic' : '')); + const links = buildScysListLinkFields(row.url ?? row.raw_url ?? best?.url ?? '', entityType, topicId, [ + ...(row.source_links ?? []), + ...(row.external_links ?? []), + row.url, + row.raw_url, + ...(best?.source_links ?? []), + ...(best?.external_links ?? []), + best?.url, + best?.raw_url, + ]); + return { + ...row, + author: row.author || best?.author || '', + time: row.time || best?.time || '', + topic_id: topicId, + entity_type: entityType, + url: links.url || row.url || best?.url || '', + raw_url: links.raw_url || row.raw_url || best?.raw_url || '', + source_links: links.source_links, + external_links: links.external_links, + }; + }); +} +async function requestScysSearchTopic(page, body) { + const response = await page.evaluate(` + (() => { + const getStorage = (name) => { + try { + return window[name]; + } catch { + return null; + } + }; + const storage = getStorage('localStorage'); + const token = storage && typeof storage.getItem === 'function' + ? (storage.getItem('__user_token.v3') || '') + : ''; + const requestBody = ${JSON.stringify(body)}; + + return (async () => { + if (!token) { + return { ok: false, status: 0, items: [], error: 'missing-token' }; + } + try { + const resp = await fetch('/shengcai-web/client/homePage/searchTopic', { + method: 'POST', + credentials: 'include', + headers: { + 'content-type': 'application/json', + 'X-TOKEN': token, + }, + body: JSON.stringify(requestBody), + }); + let json = null; + try { + json = await resp.json(); + } catch {} + const data = json?.data ?? json ?? {}; + const items = Array.isArray(data.items) ? data.items : []; + return { + ok: resp.ok, + status: resp.status, + items, + }; + } catch (error) { + return { + ok: false, + status: 0, + items: [], + error: String(error), + }; + } + })(); + })() + `); + if (!response || typeof response !== 'object' || response.ok !== true || !Array.isArray(response.items)) { + return []; + } + return response.items; +} +function normalizeScysFeedApiRows(items, limit, maxLength) { + return (items ?? []).slice(0, limit).map((item, index) => { + const topic = item?.topicDTO ?? {}; + const user = item?.topicUserDTO ?? {}; + const menuValues = Array.isArray(topic.menuList) + ? topic.menuList.map((m) => cleanText(m?.value)).filter(Boolean) + : []; + const tags = Array.from(new Set(menuValues.map((v) => polishScysText(v)).filter(Boolean))); + const topicId = cleanText(topic.topicId || topic.entityId); + const entityType = cleanText(topic.entityType || 'xq_topic'); + const links = buildScysListLinkFields(item?.detailUrl, entityType, topicId, [ + item?.detailUrl, + topic?.externalLink, + ]); + const images = Array.isArray(topic.imageList) + ? topic.imageList.map((u) => cleanText(u)).filter(Boolean) + : []; + const interactions = buildScysInteractions(topic.likeCount, topic.commentsCount, topic.favoriteCount); + const flags = topic.isDigested ? ['精华'] : []; + const summary = trimWithLimit(stripScysRichText(topic.articleContent), maxLength); + return { + rank: index + 1, + author: polishScysText(user.name), + time: formatScysRelativeTime(topic.gmtCreate), + flags, + title: polishScysText(stripScysRichText(topic.showTitle)), + summary, + tags, + interactions, + interactions_display: interactions.display, + topic_id: topicId, + entity_type: entityType, + url: links.url, + raw_url: links.raw_url, + source_links: links.source_links, + external_links: links.external_links, + images, + image_count: images.length, + }; + }).filter((row) => row.title || row.summary); +} +function normalizeScysOpportunityApiRows(items, limit) { + return (items ?? []).slice(0, limit).map((item, index) => { + const topic = item?.topicDTO ?? {}; + const user = item?.topicUserDTO ?? {}; + const menuValues = Array.isArray(topic.menuList) + ? topic.menuList.map((m) => cleanText(m?.value)).filter(Boolean) + : []; + const { flags, tags } = splitOpportunityFlagsAndTags(menuValues); + const interactions = buildScysInteractions(topic.likeCount, topic.commentsCount, topic.favoriteCount); + const entityType = cleanText(topic.entityType || 'xq_topic'); + const topicId = cleanText(topic.topicId || topic.entityId); + const images = Array.isArray(topic.imageList) + ? topic.imageList.map((u) => cleanText(u)).filter(Boolean) + : []; + const links = buildScysListLinkFields(item?.detailUrl, entityType, topicId, [ + item?.detailUrl, + topic?.externalLink, + ]); + const normalizedFlags = flags.map((f) => polishScysText(f)).filter(Boolean); + const normalizedTags = tags.map((t) => polishScysText(t)).filter(Boolean); + const summary = polishScysText(stripScysRichText(topic.articleContent)); + return { + rank: index + 1, + author: polishScysText(user.name), + time: formatScysRelativeTime(topic.gmtCreate), + flags: normalizedFlags, + title: polishScysText(stripScysRichText(topic.showTitle)), + summary, + ai_summary: polishScysText(parseAiSummaryText(topic.aiSummaryContent)), + tags: normalizedTags, + interactions, + interactions_display: interactions.display, + url: links.url, + raw_url: links.raw_url, + topic_id: topicId, + entity_type: entityType, + source_links: links.source_links, + external_links: links.external_links, + images, + image_count: images.length, + }; + }); +} +function parseCnNumberToken(token) { + const raw = cleanText(token); + if (!raw) + return 0; + const numeric = Number(raw.replace(/[万亿]/g, '')); + if (!Number.isFinite(numeric)) + return 0; + if (raw.endsWith('万')) + return Math.floor(numeric * 10_000); + if (raw.endsWith('亿')) + return Math.floor(numeric * 100_000_000); + return Math.floor(numeric); +} +function parseInteractionCounts(raw) { + const text = cleanText(raw); + if (!text) + return { likes: 0, comments: 0, favorites: 0 }; + const matched = text.match(/[0-9]+(?:\.[0-9]+)?(?:万|亿)?/g) ?? []; + return { + likes: parseCnNumberToken(matched[0] ?? ''), + comments: parseCnNumberToken(matched[1] ?? ''), + favorites: parseCnNumberToken(matched[2] ?? ''), + }; +} +function buildScysInteractions(like, comments, favorites, fallback) { + const likeCount = Number(like); + const commentCount = Number(comments); + const favoriteCount = Number(favorites); + if ([likeCount, commentCount, favoriteCount].every((n) => Number.isFinite(n) && n >= 0)) { + const likes = Math.floor(likeCount); + const commentsValue = Math.floor(commentCount); + const favoritesValue = Math.floor(favoriteCount); + return { + likes, + comments: commentsValue, + favorites: favoritesValue, + display: `点赞${likes} 评论${commentsValue} 收藏${favoritesValue}`, + }; + } + const parsed = parseInteractionCounts(fallback); + return { + ...parsed, + display: `点赞${parsed.likes} 评论${parsed.comments} 收藏${parsed.favorites}`, + }; +} +function trimWithLimit(value, maxLength) { + const text = polishScysText(value); + if (!text) + return ''; + return text.slice(0, maxLength); +} +function polishScysText(value) { + let text = cleanText(value); + if (!text) + return ''; + for (const [pattern, replacement] of SCYS_TEXT_FIXUPS) { + text = text.replace(pattern, replacement); + } + return text; +} +function extractFirstNumber(value) { + const text = cleanText(value); + if (!text) + return 0; + const match = text.match(/[0-9]+(?:\.[0-9]+)?(?:万|亿)?/); + if (!match?.[0]) + return 0; + const raw = match[0]; + const numeric = Number(raw.replace(/[万亿]/g, '')); + if (!Number.isFinite(numeric)) + return 0; + if (raw.endsWith('万')) + return Math.floor(numeric * 10_000); + if (raw.endsWith('亿')) + return Math.floor(numeric * 100_000_000); + return Math.floor(numeric); +} +function isLikelyExternalLink(url) { + if (!url) + return false; + return /^https?:\/\//i.test(url) && !/^https?:\/\/(?:www\.)?scys\.com\//i.test(url); +} +function normalizeMaybeBrokenUrl(raw) { + return cleanText(raw).replace(/\s+/g, ''); +} +function isLikelyFalsePositiveLink(url) { + const normalized = normalizeMaybeBrokenUrl(url); + if (!/^https?:\/\//i.test(normalized)) + return false; + // Heuristic: markdown/autolink-like false positives such as "7.AI" in numbered lists. + // Example false extraction: http://7.AI + return /^https?:\/\/\d+\.[a-z]{2,}\/?$/i.test(normalized); +} +function normalizeScysTocRows(rows) { + const seen = new Set(); + const out = []; + for (const row of rows ?? []) { + const entryType = cleanText(row.entry_type || 'chapter'); + const section = cleanText(row.section ?? ''); + const group = cleanText(row.group ?? ''); + const chapterId = cleanText(row.chapter_id ?? ''); + const chapterTitle = cleanText(row.chapter_title ?? ''); + const status = cleanText(row.status ?? ''); + const isCurrent = !!row.is_current; + if (!section && !group && !chapterTitle && !chapterId) + continue; + const key = [ + entryType || 'chapter', + section, + group, + chapterId, + chapterTitle, + status, + isCurrent ? '1' : '0', + ].join('|'); + if (seen.has(key)) + continue; + seen.add(key); + out.push({ + rank: out.length + 1, + entry_type: entryType === 'section' ? 'section' : 'chapter', + section, + group, + chapter_id: chapterId, + chapter_title: chapterTitle, + status, + is_current: isCurrent, + }); + } + return out; +} +async function evaluateScysTocRows(page, opts = {}) { + const shouldExpand = opts.expandCollapsedSections === true; + const rows = await page.evaluate(` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const out = []; + const seen = new Set(); + const pushRow = (row) => { + const normalized = { + entry_type: clean(row.entry_type || 'chapter'), + section: clean(row.section || ''), + group: clean(row.group || ''), + chapter_id: clean(row.chapter_id || ''), + chapter_title: clean(row.chapter_title || ''), + status: clean(row.status || ''), + is_current: !!row.is_current, + }; + if (!normalized.section && !normalized.group && !normalized.chapter_id && !normalized.chapter_title) return; + const key = [ + normalized.entry_type || 'chapter', + normalized.section, + normalized.group, + normalized.chapter_id, + normalized.chapter_title, + normalized.status, + normalized.is_current ? '1' : '0', + ].join('|'); + if (seen.has(key)) return; + seen.add(key); + out.push(normalized); + }; + + const chapterSelector = '.vc-chapter-item[data-item-id], .chapter-list .vc-chapter-item, .vc-chapter-item'; + + ${shouldExpand ? ` + const expandSections = async () => { + const sections = Array.from(document.querySelectorAll('.catalogue-section')); + for (const section of sections) { + const currentCount = section.querySelectorAll(chapterSelector).length; + const isExpanded = + section.classList.contains('expanded') || + !!section.querySelector('.vc-section-header.expanded'); + if (currentCount > 0 || isExpanded) continue; + + const sectionTitleEl = + section.querySelector('.section-title') || + section.querySelector('.vc-section-header') || + section; + + if (!sectionTitleEl) continue; + + if (typeof sectionTitleEl.click === 'function') { + sectionTitleEl.click(); + } else { + sectionTitleEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + + for (let i = 0; i < 12; i += 1) { + await sleep(200); + const count = section.querySelectorAll(chapterSelector).length; + const expandedNow = + section.classList.contains('expanded') || + !!section.querySelector('.vc-section-header.expanded'); + if (count > 0 || expandedNow) break; + } + } + }; + + await expandSections(); + ` : ''} + + const groups = Array.from(document.querySelectorAll('.vc-chapter-group')); + const sections = Array.from(document.querySelectorAll('.catalogue-section')); + + if (sections.length > 0) { + sections.forEach((section) => { + const sectionTitle = clean( + section.querySelector('.section-title, .catalogue-section-title, .title')?.textContent || '' + ); + if (sectionTitle) { + pushRow({ + entry_type: 'section', + section: sectionTitle, + group: sectionTitle, + chapter_title: sectionTitle, + chapter_id: '', + status: '', + is_current: false, + }); + } + + const sectionGroups = Array.from(section.querySelectorAll('.vc-chapter-group')); + if (sectionGroups.length > 0) { + sectionGroups.forEach((group) => { + const groupTitle = clean( + group.querySelector('.group-title, .chapter-group-title, .vc-group-title')?.textContent || '' + ); + const items = Array.from(group.querySelectorAll(chapterSelector)); + items.forEach((item) => { + const title = clean( + item.querySelector('.chapter-title')?.textContent || + item.querySelector('.chapter-content')?.textContent || + item.textContent || + '' + ); + const status = clean(item.querySelector('.chapter-status, .chapter-meta')?.textContent || ''); + const cls = item.className || ''; + const isCurrent = /active|current|selected|is-active/.test(cls) || item.getAttribute('aria-current') === 'true'; + if (!title) return; + pushRow({ + entry_type: 'chapter', + section: sectionTitle, + group: groupTitle || sectionTitle, + chapter_id: item.getAttribute('data-item-id') || '', + chapter_title: title, + status, + is_current: isCurrent, + }); + }); + }); + } + }); + } + + if (out.length === 0 && groups.length > 0) { + groups.forEach((group) => { + const groupTitle = clean( + group.querySelector('.group-title, .chapter-group-title, .vc-group-title')?.textContent || '' + ); + const sectionTitle = clean( + group.closest('.catalogue-section')?.querySelector('.section-title, .catalogue-section-title, .title')?.textContent || '' + ); + const items = Array.from(group.querySelectorAll(chapterSelector)); + items.forEach((item) => { + const title = clean( + item.querySelector('.chapter-title')?.textContent || + item.querySelector('.chapter-content')?.textContent || + item.textContent || + '' + ); + const status = clean(item.querySelector('.chapter-status, .chapter-meta')?.textContent || ''); + const cls = item.className || ''; + const isCurrent = /active|current|selected|is-active/.test(cls) || item.getAttribute('aria-current') === 'true'; + if (!title) return; + pushRow({ + entry_type: 'chapter', + section: sectionTitle, + group: groupTitle, + chapter_id: item.getAttribute('data-item-id') || '', + chapter_title: title, + status, + is_current: isCurrent, + }); + }); + }); + } + + if (out.length === 0) { + const items = Array.from(document.querySelectorAll(chapterSelector)); + items.forEach((item) => { + const title = clean( + item.querySelector('.chapter-title')?.textContent || + item.querySelector('.chapter-content')?.textContent || + item.textContent || + '' + ); + const status = clean(item.querySelector('.chapter-status, .chapter-meta')?.textContent || ''); + const cls = item.className || ''; + const isCurrent = /active|current|selected|is-active/.test(cls) || item.getAttribute('aria-current') === 'true'; + if (!title) return; + pushRow({ + entry_type: 'chapter', + section: '', + group: '', + chapter_id: item.getAttribute('data-item-id') || '', + chapter_title: title, + status, + is_current: isCurrent, + }); + }); + } + + return out; + })() + `); + return normalizeScysTocRows(rows); +} +function polishScysCourseSummary(summary, courseId, maxLength) { + return { + ...summary, + course_id: courseId, + course_title: polishScysText(summary.course_title), + chapter_title: polishScysText(summary.chapter_title), + breadcrumb: polishScysText(summary.breadcrumb), + content: polishScysText(repairScysBrokenUrls(summary.content)).slice(0, maxLength), + toc_summary: polishScysText(summary.toc_summary), + updated_at_text: polishScysText(summary.updated_at_text), + copyright_text: polishScysText(summary.copyright_text), + prev_chapter: polishScysText(summary.prev_chapter), + next_chapter: polishScysText(summary.next_chapter), + discussion_hint: polishScysText(summary.discussion_hint), + url: summary.url || '', + raw_url: summary.raw_url || '', + links: Array.from(new Set((summary.links ?? []).map((link) => cleanText(link)).filter(Boolean))), + images: Array.from(new Set((summary.images ?? []).map((link) => cleanText(link)).filter(Boolean))), + content_images: Array.from(new Set((summary.content_images ?? []).map((link) => cleanText(link)).filter(Boolean))), + image_count: Array.isArray(summary.images) ? summary.images.length : 0, + content_image_count: Array.isArray(summary.content_images) ? summary.content_images.length : 0, + image_dir: summary.image_dir || '', + }; +} +async function extractScysCourseSingle(page, inputUrl, opts = {}) { + const url = toScysCourseUrl(inputUrl); + const waitSeconds = Math.max(1, Number(opts.waitSeconds ?? 3)); + const maxLength = Math.max(300, Number(opts.maxLength ?? 4000)); + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + const tocRows = opts.tocRows ?? await evaluateScysTocRows(page); + const payload = await page.evaluate(` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const normalizeUrl = (value) => clean(value).replace(/\\s+/g, ''); + const abs = (href) => { + const raw = normalizeUrl(href); + if (!raw) return ''; + if (raw.startsWith('http://') || raw.startsWith('https://')) return raw; + if (raw.startsWith('//')) return location.protocol + raw; + if (raw.startsWith('/')) return location.origin + raw; + return ''; + }; + const uniq = (list) => Array.from(new Set(list.filter(Boolean))); + const pickFirstText = (selectors) => { + for (const selector of selectors) { + const el = document.querySelector(selector); + const text = clean(el?.textContent || el?.innerText || ''); + if (text) return text; + } + return ''; + }; + const pickFirstEl = (selectors) => { + for (const selector of selectors) { + const el = document.querySelector(selector); + if (el) return el; + } + return null; + }; + const bodyText = clean(document.body?.innerText || ''); + const capture = (matcher) => clean(bodyText.match(matcher)?.[0] || ''); + + const contentEl = pickFirstEl([ + '.feishu-doc-content', + '.document-container', + '.vc-course-content', + '.course-content-container', + '.content-container', + '.vc-course-main', + ]); + + const breadcrumbTexts = Array.from( + document.querySelectorAll( + '.simple-catalog-toggle .breadcrumb-item, .breadcrumb-item, .breadcrumb a, .breadcrumb span, .vc-breadcrumb a, .vc-breadcrumb span' + ) + ) + .map((el) => clean(el.textContent || '')) + .filter(Boolean); + + const chapterItems = Array.from(document.querySelectorAll('.vc-chapter-item[data-item-id], .chapter-list .vc-chapter-item')).map((el) => { + const item = el; + const id = clean(item.getAttribute('data-item-id') || ''); + const title = clean( + item.querySelector('.chapter-title')?.textContent || + item.querySelector('.chapter-content')?.textContent || + item.textContent || + '' + ); + const cls = item.className || ''; + const isCurrent = /active|current|selected|is-active/.test(cls) || item.getAttribute('aria-current') === 'true'; + return { id, title, isCurrent }; + }).filter((row) => row.title); + + const chapterIdFromQuery = new URL(location.href).searchParams.get('chapterId') || ''; + const chapterId = chapterIdFromQuery || chapterItems.find((item) => item.isCurrent)?.id || ''; + const activeChapterEl = + document.querySelector('.vc-chapter-item.is-active, .vc-chapter-item.is-current, .vc-chapter-item.active') || + null; + const activeGroupTitle = clean( + activeChapterEl?.closest('.vc-chapter-group')?.querySelector('.group-title, .chapter-group-title')?.textContent || '' + ); + const activeSectionTitle = clean( + activeChapterEl?.closest('.catalogue-section')?.querySelector('.section-title, .catalogue-section-title, .title')?.textContent || '' + ); + const activeChapterTitle = clean(activeChapterEl?.querySelector('.chapter-title')?.textContent || ''); + const catalogBreadcrumb = [activeSectionTitle, activeGroupTitle, activeChapterTitle].filter(Boolean); + + const courseTitle = + pickFirstText([ + '.vc-course-main .course-name', + '.course-name', + '.vc-course-sidebar .course-title', + '.course-header .course-title', + '.course-title', + ]) || + clean((document.title || '').split(' - ')[0] || ''); + + const chapterTitleFromContent = pickFirstText([ + '.vc-course-content .content-title', + '.course-content-container .content-title', + '.content-title', + '.current-chapter', + '.vc-course-main h1', + 'h1', + ]); + + const allImages = uniq( + Array.from(document.querySelectorAll('img')) + .map((img) => abs(img.currentSrc || img.getAttribute('src') || img.getAttribute('data-src') || '')) + ); + const contentImages = uniq( + Array.from(contentEl?.querySelectorAll?.('img') || []) + .map((img) => abs(img.currentSrc || img.getAttribute('src') || img.getAttribute('data-src') || '')) + ); + const links = uniq( + Array.from(contentEl?.querySelectorAll?.('a[href]') || []) + .map((link) => abs(link.getAttribute('href') || '')) + ); + + return { + courseTitle, + chapterTitle: chapterTitleFromContent, + currentChapter: + chapterItems.find((item) => item.id === chapterId)?.title || + chapterItems.find((item) => item.isCurrent)?.title || + activeChapterTitle || + chapterTitleFromContent, + breadcrumb: catalogBreadcrumb.length >= 2 ? catalogBreadcrumb : breadcrumbTexts, + content: clean(contentEl?.innerText || ''), + chapterId, + pageUrl: location.href, + images: allImages, + contentImages, + links, + updatedAtText: capture(/更新于[::]?\\s*[0-9]{4}[./-][0-9]{2}[./-][0-9]{2}\\s*[0-9]{2}:[0-9]{2}/), + copyrightText: capture(/版权归[^。!?]{0,120}(?:。|$)/), + prevChapter: bodyText.includes('上一节') ? '上一节' : '', + nextChapter: bodyText.includes('下一节') ? '下一节' : '', + participantText: capture(/\\d+\\s*人参与/), + discussionHint: bodyText.includes('发起讨论') ? '发起讨论' : (bodyText.includes('讨论区') ? '讨论区' : ''), + }; + })() + `); + if (!payload) { + throw new EmptyResultError('scys/course', 'Failed to extract course page content'); + } + const courseId = extractScysCourseId(url); + const normalized = normalizeScysCoursePayload({ + courseTitle: payload.courseTitle, + chapterTitle: payload.chapterTitle, + currentChapter: payload.currentChapter, + breadcrumb: Array.isArray(payload.breadcrumb) ? payload.breadcrumb : [], + content: payload.content, + chapterId: payload.chapterId, + pageUrl: String(payload.pageUrl || url), + images: Array.isArray(payload.images) ? payload.images : [], + contentImages: Array.isArray(payload.contentImages) ? payload.contentImages : [], + links: Array.isArray(payload.links) ? payload.links : [], + tocRows, + updatedAtText: payload.updatedAtText, + copyrightText: payload.copyrightText, + prevChapter: payload.prevChapter, + nextChapter: payload.nextChapter, + discussionHint: payload.discussionHint, + participantText: payload.participantText, + }); + const result = polishScysCourseSummary({ + ...normalized, + url: normalized.url || normalizeScysUrl(url), + raw_url: normalized.raw_url || normalizeScysUrl(url), + }, courseId, maxLength); + if (!result.content && tocRows.length === 0) { + throw new EmptyResultError('scys/course', 'No course content or table of contents was detected'); + } + return result; +} +export async function ensureScysFeedReady(page) { + await page.evaluate(` + (async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + for (let i = 0; i < 12; i += 1) { + const hasCards = document.querySelectorAll('.post-list-container .compact-card, .compact-card').length > 0; + const hasControls = document.querySelector('.vc-secondary-filter .filter-item, .titles.selector .button, .select.wrap .button'); + if (hasCards || hasControls) return; + await sleep(250); + } + })() + `); +} +export async function ensureScysLogin(page) { + const state = await page.evaluate(` + (() => { + const text = (document.body?.innerText || '').slice(0, 12000); + const strongLoginText = /扫码登录|手机号登录|验证码登录|微信登录|账号登录|登录\\/注册/.test(text); + const genericLoginText = /请登录|登录后/.test(text); + const loginCtaText = /立即登录|去登录|重新登录|登录查看|登录后查看|登录可见|请先登录/.test(text); + const loginByDom = !!document.querySelector( + '.login-container, .login-box, .qrcode-login, .login-btn, .btn-login, .auth-mask, .auth-dialog, form[action*="login"], input[type="password"], input[type="tel"][placeholder*="手机号"], button[class*="login"], a[href*="login"]' + ); + const hasContentSignals = !!document.querySelector( + '.course-detail-page, .vc-course-main, .post-list-container, .compact-card, .activity-left, .week-card, .vc-secondary-filter' + ); + const routeLooksLikeLogin = location.pathname.includes('/login'); + return { strongLoginText, genericLoginText, loginCtaText, loginByDom, hasContentSignals, routeLooksLikeLogin }; + })() + `); + if (!state) + return; + const shouldBlock = !!state.routeLooksLikeLogin + || !!state.loginByDom + || (!!state.loginCtaText && !state.hasContentSignals) + || (!!state.strongLoginText && !state.hasContentSignals) + || (!!state.genericLoginText && !state.hasContentSignals); + if (shouldBlock) { + throw new AuthRequiredError(SCYS_DOMAIN, 'SCYS content requires a logged-in browser session'); + } +} +async function captureScysPageCache(page) { + const entries = await page.evaluate(` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const normalizeUrl = (value) => clean(value).replace(/\\s+/g, ''); + const abs = (href) => { + const raw = normalizeUrl(href); + if (!raw) return ''; + if (raw.startsWith('http://') || raw.startsWith('https://')) return raw; + if (raw.startsWith('/')) return location.origin + raw; + return ''; + }; + const uniq = (list) => Array.from(new Set(list.filter(Boolean))); + const shouldParseStorageValue = (value) => + typeof value === 'string' && /(topicId|entityId|showTitle|articleContent|topicDTO|articleDetail|scys_url)/.test(value); + const getStorage = (name) => { + try { + return window[name]; + } catch { + return null; + } + }; + const collectStorageRoots = (storage) => { + const out = []; + if (!storage) return out; + for (let i = 0; i < storage.length; i += 1) { + const key = storage.key(i); + if (!key) continue; + const raw = storage.getItem(key); + if (!shouldParseStorageValue(raw)) continue; + try { + out.push(JSON.parse(raw)); + } catch { + // Ignore non-JSON blobs. + } + } + return out; + }; + const roots = [ + window.__NUXT__, + window.__INITIAL_STATE__, + window.__NEXT_DATA__, + window.$nuxt && window.$nuxt.$store && window.$nuxt.$store.state, + window.$nuxt && window.$nuxt.context && window.$nuxt.context.store && window.$nuxt.context.store.state, + window.__PINIA__ && window.__PINIA__.state && window.__PINIA__.state.value, + ...collectStorageRoots(getStorage('localStorage')), + ...collectStorageRoots(getStorage('sessionStorage')), + ].filter(Boolean); + const seen = new WeakSet(); + const out = []; + const urlPattern = /https?:\\/\\/[^\\s"'<>]+/g; + const visit = (value, depth = 0) => { + if (!value || typeof value !== 'object' || depth > 7) return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((item) => visit(item, depth + 1)); + return; + } + + const topic = value.topicDTO && typeof value.topicDTO === 'object' ? value.topicDTO : value; + const topicId = clean(topic.topicId || topic.entityId || value.topic_id || value.topicId || ''); + const entityType = clean(topic.entityType || value.entity_type || value.entityType || (topicId ? 'xq_topic' : '')); + const title = clean(topic.showTitle || topic.title || value.title || ''); + const articleContent = String(topic.articleContent || value.content_preview || value.content || ''); + const rawLinks = [ + ...(Array.isArray(value.links) ? value.links : []), + ...(Array.isArray(value.feishu_links) ? value.feishu_links : []), + ...(Array.isArray(value.source_links) ? value.source_links : []), + ...(Array.isArray(value.external_links) ? value.external_links : []), + topic.externalLink, + value.externalLink, + value.url, + value.raw_url, + ]; + const inlineLinks = Array.from(articleContent.match(urlPattern) || []); + const links = uniq([...rawLinks, ...inlineLinks].map((item) => abs(item)).filter(Boolean)); + const scysUrl = abs(value.scys_url || value.detailUrl || (topicId && entityType ? '/articleDetail/' + entityType + '/' + topicId : '')); + if ((topicId || scysUrl) && (title || articleContent || links.length > 0)) { + out.push({ + title, + topic_id: topicId, + entity_type: entityType, + scys_url: scysUrl, + links, + author: clean(value.author || value.nickname || value.name || ''), + time: clean(value.time || ''), + }); + } + + Object.values(value).forEach((child) => visit(child, depth + 1)); + }; + roots.forEach((root) => visit(root, 0)); + return out; + })() + `); + return Array.isArray(entries) ? entries : []; +} +async function readScysArticlePayload(page) { + return page.evaluate(` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const normalizeUrl = (value) => clean(value).replace(/\\s+/g, ''); + const abs = (href) => { + const raw = normalizeUrl(href); + if (!raw) return ''; + if (raw.startsWith('http://') || raw.startsWith('https://')) return raw; + if (raw.startsWith('/')) return location.origin + raw; + return ''; + }; + const uniq = (list) => Array.from(new Set(list.filter(Boolean))); + const pickText = (selectors) => { + for (const selector of selectors) { + const text = clean(document.querySelector(selector)?.textContent || ''); + if (text) return text; + } + return ''; + }; + + const articleMatch = location.pathname.match(/^\\/articleDetail\\/([^/]+)\\/([^/]+)/); + const entityType = clean(articleMatch?.[1] || ''); + const topicId = clean(articleMatch?.[2] || ''); + + const title = pickText([ + '.title-line .post-title', + '.post-title', + '.article-title', + '.topic-title', + 'h1', + ]) || clean(document.title || ''); + const author = pickText([ + '.post-item-top-right .name', + '.post-item-top .name', + '.post-item-top-right .user-name', + '.post-item-top .user-name', + ]); + const time = pickText([ + '.post-item-top-right .date', + '.post-item-top .date', + '.post-item-top-right .time', + '.post-item-top .time', + ]); + + const content = pickText([ + '.post-content', + '.content-container .post-content', + '.content-container', + ]); + const aiSummary = pickText([ + '.ai-summary-container .content', + '.ai-summary-container .content-stream', + '.ai-summary-container', + ]); + + const flags = uniq( + Array.from(document.querySelectorAll('.title-line .icon, .title-line .tag, .title-line .flag')) + .map((el) => clean(el.textContent || '')) + ); + const tags = uniq( + Array.from(document.querySelectorAll('.label-box .tag-item, .tag-label-box .tag-item, .label-box .tag')) + .map((el) => clean(el.textContent || '')) + ); + + const interactionNodes = Array.from( + document.querySelectorAll('.interactions .item, .interactions .favorite-wrapper, .interactions .favorite-wrapper .item') + ).map((el) => ({ + cls: (el.className || '').toString(), + text: clean(el.textContent || ''), + })); + + const likeText = clean(document.querySelector('.interactions .like-item')?.textContent || ''); + const favoriteText = clean( + document.querySelector('.interactions .favorite-wrapper .item')?.textContent || + document.querySelector('.interactions .favorite-wrapper')?.textContent || + '' + ); + const commentText = clean( + interactionNodes.find((node) => /item/.test(node.cls) && !/like/.test(node.cls) && /^[0-9]/.test(node.text))?.text || '' + ); + + const imageCandidates = Array.from( + document.querySelectorAll('.image-list-container img, .arco-carousel img, .post-content img, .content-container img') + ) + .map((img) => abs(img.getAttribute('src') || img.getAttribute('data-src') || '')) + .map((src) => normalizeUrl(src)) + .filter(Boolean) + .filter((src) => !src.startsWith('data:')) + .filter((src) => !src.includes('/upload/avatar/')) + .filter((src) => !src.includes('/images/img_bg_empty')) + .filter((src) => /\\/xq\\/images\\/|\\.(jpg|jpeg|png|webp|gif)(\\?|$)/i.test(src)); + const images = uniq(imageCandidates); + + const sourceLinks = uniq( + Array.from(document.querySelectorAll('.post-content a[href], .content-container a[href]')) + .map((a) => abs(a.getAttribute('href') || '')) + .map((href) => normalizeUrl(href)) + ); + const externalLinks = sourceLinks.filter((href) => /^https?:\\/\\//i.test(href) && !/^https?:\\/\\/(?:www\\.)?scys\\.com\\//i.test(href)); + + return { + entityType, + topicId, + title, + author, + time, + flags, + tags, + content, + aiSummary, + likeText, + commentText, + favoriteText, + images, + sourceLinks, + externalLinks, + pageUrl: location.href, + }; + })() + `); +} +function isShellScysTitle(value) { + return SCYS_SHELL_TITLES.has(cleanText(value)); +} +function isScysArticleHydrated(payload) { + if (!payload) + return false; + const title = cleanText(payload.title); + const content = cleanText(payload.content); + const aiSummary = cleanText(payload.aiSummary); + const author = cleanText(payload.author); + const time = cleanText(payload.time); + const sourceLinks = Array.isArray(payload.sourceLinks) ? payload.sourceLinks.filter(Boolean) : []; + const images = Array.isArray(payload.images) ? payload.images.filter(Boolean) : []; + if (!title && !content && !aiSummary) + return false; + if (isShellScysTitle(title) && !content && !aiSummary && !author && !time && sourceLinks.length === 0 && images.length === 0) { + return false; + } + return true; +} +async function waitForScysArticlePayload(page, attempts = 3) { + let lastPayload = null; + for (let index = 0; index < attempts; index += 1) { + const payload = await readScysArticlePayload(page); + lastPayload = payload; + if (isScysArticleHydrated(payload)) + return payload; + if (index < attempts - 1) { + await page.wait(1); + } + } + return lastPayload; +} +async function resolveScysArticlePayload(page, url, waitSeconds) { + let payload = null; + const maxRounds = 3; + const retryWaitSeconds = Math.max(1, Math.min(waitSeconds, 2)); + for (let round = 0; round < maxRounds; round += 1) { + payload = await waitForScysArticlePayload(page, 3); + if (isScysArticleHydrated(payload)) { + return payload; + } + if (round < maxRounds - 1) { + // A same-URL goto can be short-circuited by the browser bridge. + // Bounce through the SCYS home page first so the article route + // really re-enters and triggers a fresh hydrate. + await gotoAndWait(page, 'https://scys.com/', 1); + await ensureScysLogin(page); + const separator = url.includes('?') ? '&' : '?'; + const retryUrl = `${url}${separator}_opencli_retry=${Date.now()}_${round + 1}`; + await gotoAndWait(page, retryUrl, retryWaitSeconds); + await ensureScysLogin(page); + } + } + return payload; +} +export async function extractScysCourse(page, inputUrl, opts = {}) { + return extractScysCourseSingle(page, inputUrl, opts); +} +export async function extractScysCourseAll(page, inputUrl, opts = {}) { + const tocRows = await extractScysToc(page, inputUrl, opts); + const urls = buildScysCourseChapterUrls(inputUrl, tocRows); + if (urls.length === 0) { + throw new EmptyResultError('scys/course', 'No chapter ids were detected for deterministic full-course export'); + } + const out = []; + for (const url of urls) { + out.push(await extractScysCourseSingle(page, url, { ...opts, tocRows })); + } + return out; +} +export async function extractScysToc(page, courseInput, opts = {}) { + const url = toScysCourseUrl(courseInput); + const waitSeconds = Math.max(1, Number(opts.waitSeconds ?? 2)); + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + const normalized = await evaluateScysTocRows(page, { expandCollapsedSections: true }); + if (normalized.length === 0) { + await ensureScysLogin(page); + throw new EmptyResultError('scys/toc', 'No chapter list was detected on this course page. If your SCYS browser session expired, reopen scys.com in Chrome, log in again, then retry.'); + } + return normalized; +} +export async function extractScysArticle(page, inputUrl, opts = {}) { + const requestedUrl = toScysArticleUrl(inputUrl); + const url = requestedUrl.replace(/[?#].*$/, ''); + const waitSeconds = Math.max(1, Number(opts.waitSeconds ?? 5)); + const maxLength = Math.max(300, Number(opts.maxLength ?? 4000)); + const fromUrl = extractScysArticleMeta(url); + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + const payload = await resolveScysArticlePayload(page, url, waitSeconds); + if (!payload) { + throw new EmptyResultError('scys/article', 'Failed to extract article detail page'); + } + const rawFlags = (payload.flags ?? []).map((value) => polishScysText(value)).filter(Boolean); + const rawTags = (payload.tags ?? []).map((value) => polishScysText(value)).filter(Boolean); + const split = splitOpportunityFlagsAndTags([...rawFlags, ...rawTags]); + const flags = Array.from(new Set([ + ...rawFlags, + ...split.flags.map((value) => polishScysText(value)).filter(Boolean), + ])); + const tags = Array.from(new Set([ + ...rawTags, + ...split.tags.map((value) => polishScysText(value)).filter(Boolean), + ])).filter((tag) => !flags.includes(tag)); + const interactions = buildScysInteractions(extractFirstNumber(payload.likeText), extractFirstNumber(payload.commentText), extractFirstNumber(payload.favoriteText)); + const sourceLinks = Array.from(new Set((payload.sourceLinks ?? []) + .map((href) => normalizeMaybeBrokenUrl(href)) + .filter(Boolean) + .filter((href) => !isLikelyFalsePositiveLink(href)))); + const externalLinks = Array.from(new Set((payload.externalLinks ?? []) + .map((href) => normalizeMaybeBrokenUrl(href)) + .filter(isLikelyExternalLink) + .filter((href) => !isLikelyFalsePositiveLink(href)))); + const images = Array.from(new Set((payload.images ?? []).map((src) => normalizeMaybeBrokenUrl(src)).filter(Boolean))); + const content = polishScysText(stripScysRichText(payload.content ?? '')).slice(0, maxLength); + const aiSummary = polishScysText(stripScysRichText(payload.aiSummary ?? '')).slice(0, maxLength); + const title = polishScysText(payload.title ?? ''); + const author = polishScysText(payload.author ?? ''); + if (isShellScysTitle(title) && !content && !aiSummary && !author && !cleanText(payload.time ?? '') && sourceLinks.length === 0 && images.length === 0) { + throw new EmptyResultError('scys/article', 'Article detail page did not hydrate beyond shell content'); + } + if (!title && !content && !aiSummary) { + throw new EmptyResultError('scys/article', 'No title/content was detected on this article page'); + } + return { + entity_type: polishScysText(payload.entityType || fromUrl.entityType), + topic_id: polishScysText(payload.topicId || fromUrl.topicId), + url: normalizeScysUrl(payload.pageUrl || url), + title, + author, + time: polishScysText(payload.time ?? ''), + tags, + flags, + content, + ai_summary: aiSummary, + interactions, + image_count: images.length, + images, + external_link_count: externalLinks.length, + external_links: externalLinks, + source_links: sourceLinks, + raw_url: normalizeScysUrl(payload.pageUrl || url), + }; +} +export async function extractScysFeed(page, inputUrl, opts = {}) { + const url = normalizeScysUrl(inputUrl); + const waitSeconds = Math.max(1, Number(opts.waitSeconds ?? 3)); + const limit = Math.max(1, Number(opts.limit ?? 20)); + const maxLength = Math.max(120, Number(opts.maxLength ?? 600)); + const parsedUrl = new URL(url); + const isHomeEssence = (parsedUrl.pathname === '/' || parsedUrl.pathname === '') + && (parsedUrl.searchParams.get('filter') || '').toLowerCase() === 'essence'; + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + await ensureScysFeedReady(page); + let normalized = []; + if (isHomeEssence) { + normalized = normalizeScysFeedApiRows(await requestScysSearchTopic(page, { + pageIndex: 1, + pageSize: Math.max(limit, 30), + orderBy: 'gmt_create', + orderDirection: 'desc', + displayMode: 2, + pageScene: 'homePage', + isDigested: true, + }), limit, maxLength); + } + if (normalized.length === 0) { + await page.installInterceptor('shengcai-web/client'); + await page.evaluate(` + (async () => { + const clean = (v) => (v || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const isActive = (el) => /active|is-active|selected/.test(el?.className || ''); + + const search = new URL(location.href).searchParams; + const expectedFilter = (search.get('filter') || '').toLowerCase(); + + const homeFilters = Array.from(document.querySelectorAll('.vc-secondary-filter .filter-item')); + if (homeFilters.length > 0) { + const targetLabel = expectedFilter === 'essence' ? '精华' : '全部'; + const target = homeFilters.find((el) => clean(el.textContent || '') === targetLabel) || homeFilters[0]; + const active = homeFilters.find((el) => isActive(el)); + const alt = homeFilters.find((el) => el !== target); + if (active && target && active === target && alt) { + alt.click(); + await sleep(900); + } + if (target) { + target.click(); + await sleep(1200); + } + } + + const profileTabs = Array.from(document.querySelectorAll('.titles.selector .button, .select.wrap .button, .button')) + .filter((el) => ['帖子', '收藏'].includes(clean(el.textContent || ''))); + if (profileTabs.length > 0) { + const posts = profileTabs.find((el) => clean(el.textContent || '') === '帖子') || profileTabs[0]; + const alt = profileTabs.find((el) => el !== posts); + if (posts && isActive(posts) && alt) { + alt.click(); + await sleep(1000); + } + if (posts) { + posts.click(); + await sleep(1200); + } + } + + window.scrollTo(0, document.body.scrollHeight); + await sleep(800); + window.scrollTo(0, 0); + await sleep(300); + })() + `); + const intercepted = await page.getInterceptedRequests(); + const candidates = intercepted + .map((entry) => entry?.data ?? entry) + .filter((data) => data && Array.isArray(data.items) && data.items.some((item) => item?.topicDTO)); + const latest = candidates.at(-1); + if (latest?.items?.length) { + normalized = normalizeScysFeedApiRows(latest.items, limit, maxLength); + } + } + // DOM fallback for cases where interceptor is blocked or request timing misses. + if (normalized.length === 0) { + await page.autoScroll({ times: 2, delayMs: 1200 }); + const rows = await page.evaluate(` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const abs = (href) => { + if (!href) return ''; + if (href.startsWith('http://') || href.startsWith('https://')) return href; + if (href.startsWith('/')) return location.origin + href; + return ''; + }; + + const cards = Array.from(document.querySelectorAll('.post-list-container .compact-card, .compact-card')); + return cards.map((card) => { + const userLine = clean(card.querySelector('.user-line')?.textContent || ''); + const author = clean( + card.querySelector('.user-line .user-name, .avatar-group .user-name, .author-name')?.textContent || '' + ); + const time = clean(card.querySelector('.user-line .time-label, .user-line .time')?.textContent || ''); + const badge = clean(card.querySelector('.vc-essence-badge, .badge')?.textContent || ''); + const title = clean(card.querySelector('.title-text, .title-line .title, .title-line')?.textContent || ''); + const preview = clean(card.querySelector('.content-preview, .preview, .content')?.textContent || ''); + const tags = Array.from(card.querySelectorAll('.tags .tag, .tags span, .tag-list .tag')) + .map((el) => clean(el.textContent || '')) + .filter(Boolean); + const interactions = clean(card.querySelector('.compact-interactions, .interactions')?.textContent || ''); + const metaLine = clean(card.querySelector('.meta-line')?.textContent || ''); + const links = Array.from(card.querySelectorAll('a[href]')) + .map((el) => abs(el.getAttribute('href') || '')) + .filter(Boolean); + + return { + author, + time, + user_line: userLine, + badge, + title, + preview, + tags, + interactions, + meta_line: metaLine, + links, + }; + }).filter((item) => item.title || item.preview); + })() + `); + normalized = (rows ?? []).slice(0, limit).map((row, index) => { + const userLine = cleanText(row.user_line ?? '') + .replace(/复制链接|跳转星球|投诉建议/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const [authorByLine, timeByLine] = userLine.split('·').map((part) => cleanText(part)); + const tags = Array.from(new Set((row.tags ?? []).map((tag) => polishScysText(tag)).filter(Boolean))); + const flags = row.badge ? [polishScysText(row.badge)] : []; + const summary = trimWithLimit(row.preview ?? '', maxLength); + const interactions = buildScysInteractions(undefined, undefined, undefined, row.interactions || row.meta_line); + const preferredUrl = pickPreferredScysLink(row.links ?? []); + const meta = getScysArticleMetaFromUrl(preferredUrl); + const links = buildScysListLinkFields(preferredUrl, meta.entityType, meta.topicId, row.links ?? []); + return { + rank: index + 1, + author: polishScysText(row.author ?? authorByLine), + time: cleanText(row.time ?? timeByLine), + flags, + title: polishScysText(row.title ?? '').replace(/^(精华|热门)\s*/, ''), + summary, + tags, + interactions, + interactions_display: interactions.display, + topic_id: meta.topicId, + entity_type: meta.entityType, + url: links.url, + raw_url: links.raw_url, + source_links: links.source_links, + external_links: links.external_links, + images: [], + image_count: 0, + }; + }).filter((row) => row.title || row.summary); + } + normalized = enrichScysListRows(normalized, await captureScysPageCache(page)); + if (normalized.length === 0) { + throw new EmptyResultError('scys/feed', 'No feed cards were detected on this page'); + } + return normalized; +} +export async function extractScysOpportunity(page, inputUrl, opts = {}) { + const url = normalizeScysUrl(inputUrl); + const waitSeconds = Math.max(1, Number(opts.waitSeconds ?? 3)); + const limit = Math.max(1, Number(opts.limit ?? 20)); + const tab = normalizeOpportunityTab(opts.tab); + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + let normalized = normalizeScysOpportunityApiRows(await requestScysSearchTopic(page, { + pageIndex: 1, + pageSize: Math.max(limit, 20), + orderBy: 'gmt_create', + orderDirection: 'desc', + displayMode: 3, + sortKeyNeedDefaultGtZero: true, + pageScene: 'fxb', + sortKeyNeedDefaultGtNum: 10, + ...(tab.key === 'winning' ? { mustMenuIdList: [539] } : {}), + }), limit); + if (normalized.length === 0) { + await page.installInterceptor('shengcai-web/client/homePage/searchTopic'); + await page.evaluate(` + (async () => { + const clean = (v) => (v || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const target = ${JSON.stringify(tab.label)}; + const filters = Array.from(document.querySelectorAll('.vc-secondary-filter .filter-item')); + const hit = filters.find((el) => clean(el.textContent || '') === target); + const active = filters.find((el) => (el.className || '').includes('active')); + const alt = filters.find((el) => el !== hit); + + // Trigger request even when the current tab is already active: + // switch away once, then switch back to target. + if (active && hit && active === hit && alt) { + alt.click(); + await sleep(1000); + } + + if (hit) { + hit.click(); + } else if (filters.length > 0) { + filters[0].click(); + } + + await sleep(1400); + window.scrollTo(0, document.body.scrollHeight); + await sleep(800); + })() + `); + const intercepted = await page.getInterceptedRequests(); + const candidates = intercepted + .map((entry) => entry?.data ?? entry) + .filter((data) => data && Array.isArray(data.items) && data.items.length > 0); + const latest = candidates.at(-1); + if (latest?.items?.length) { + normalized = normalizeScysOpportunityApiRows(latest.items, limit); + } + } + // DOM fallback: keep the previous extractor as backup when the API payload is blocked. + if (normalized.length === 0) { + await page.autoScroll({ times: 2, delayMs: 1200 }); + const rows = await page.evaluate(` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const abs = (href) => { + if (!href) return ''; + if (href.startsWith('http://') || href.startsWith('https://')) return href; + if (href.startsWith('/')) return location.origin + href; + return ''; + }; + const cards = Array.from(document.querySelectorAll('.post-list-container .post-item, .post-item')); + return cards.map((card) => { + const top = card.querySelector('.post-item-top') || card; + const author = clean(top.querySelector('.name, .author, .nickname, .user-name')?.textContent || ''); + const time = clean(top.querySelector('.date, .time, .meta-time')?.textContent || ''); + const flags = Array.from(card.querySelectorAll('.hit-icon, .icon, .post-title .tag, .post-title .flag')) + .map((el) => clean(el.textContent || '')) + .filter(Boolean); + const title = clean(card.querySelector('.post-title, .title-line')?.textContent || ''); + const content = clean(card.querySelector('.content-stream, .post-content, .content-preview')?.textContent || ''); + const aiSummary = clean(card.querySelector('.ai-summary-container .content, .ai-summary-container, .ai-summary')?.textContent || ''); + const tags = Array.from(card.querySelectorAll('.label-box .tag-item, .label-box span, .tags .tag')) + .map((el) => clean(el.textContent || '')) + .filter(Boolean); + const interactions = clean(card.querySelector('.interactions, .compact-interactions')?.textContent || ''); + const images = Array.from(card.querySelectorAll('.image-list img, img.multi-img')) + .map((img) => clean(img.getAttribute('src') || img.getAttribute('data-src') || '')) + .filter(Boolean); + const link = abs(card.querySelector('a[href]')?.getAttribute('href') || ''); + return { author, time, flags, title, content, ai_summary: aiSummary, tags, interactions, link, image_urls: images }; + }).filter((item) => item.title || item.content); + })() + `); + normalized = (rows ?? []).slice(0, limit).map((row, index) => { + const images = (row.image_urls ?? []).map((u) => cleanText(u)).filter(Boolean); + const inferredTopicId = inferTopicIdFromImageUrls(images); + const preferredUrl = cleanText(row.link ?? '') || buildScysTopicLink('xq_topic', inferredTopicId); + const meta = getScysArticleMetaFromUrl(preferredUrl); + const topicId = cleanText(meta.topicId || inferredTopicId); + const entityType = cleanText(meta.entityType || (topicId ? 'xq_topic' : '')); + const tags = Array.from(new Set((row.tags ?? []).map((tag) => cleanText(tag)).filter(Boolean))); + const interactions = buildScysInteractions(undefined, undefined, undefined, row.interactions ?? ''); + const summary = polishScysText(stripScysRichText(row.content ?? '')); + const links = buildScysListLinkFields(preferredUrl, entityType, topicId, [row.link ?? '']); + const normalizedFlags = (row.flags ?? []).map((f) => polishScysText(f)).filter(Boolean); + return { + rank: index + 1, + author: polishScysText(row.author ?? ''), + time: cleanText(row.time ?? ''), + flags: normalizedFlags, + title: polishScysText(stripScysRichText(row.title ?? '')), + summary, + ai_summary: polishScysText(stripScysRichText(row.ai_summary ?? '')), + tags: tags.map((tag) => polishScysText(tag)).filter(Boolean), + interactions, + interactions_display: interactions.display, + url: links.url, + raw_url: links.raw_url, + topic_id: topicId, + entity_type: entityType, + source_links: links.source_links, + external_links: links.external_links, + images, + image_count: images.length, + }; + }); + } + normalized = enrichScysListRows(normalized, await captureScysPageCache(page)); + if (normalized.length === 0) { + throw new EmptyResultError('scys/opportunity', 'No opportunity cards were detected on this page'); + } + return normalized; +} +export async function extractScysActivity(page, inputUrl, opts = {}) { + const url = normalizeScysUrl(inputUrl); + const waitSeconds = Math.max(1, Number(opts.waitSeconds ?? 3)); + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + const payload = await page.evaluate(` + (async () => { + const clean = (value) => (value || '').replace(/\s+/g, ' ').trim(); + const normalizeTab = (value) => clean(value).replace(/\s*New$/i, '').trim(); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + const contentTabs = Array.from(document.querySelectorAll('.activity-left .container.v-no-scrollbar span, .container.v-no-scrollbar span')) + .filter((el) => clean(el.textContent || '')); + const roadmapTab = contentTabs.find((el) => clean(el.textContent || '').includes('航线图')); + if (roadmapTab && typeof roadmapTab.click === 'function') { + roadmapTab.click(); + await sleep(500); + } + + const title = clean( + document.querySelector('.activity-left .name, h1, .activity-title, .landing-title')?.textContent || + document.title || + '' + ); + const subtitle = clean( + document.querySelector('.activity-left .des, .subtitle, .sub-title, .activity-subtitle, .landing-subtitle')?.textContent || + '' + ); + + const tabGroups = Array.from( + document.querySelectorAll('.activity-left .tabs, .activity-left .container.v-no-scrollbar, .tabs') + ) + .map((group) => + Array.from(group.querySelectorAll('.tab-item, .tab, [role="tab"], .item, span')) + .map((el) => normalizeTab(el.textContent || '')) + .filter(Boolean) + ) + .filter((group) => group.length > 0); + const tabsRaw = + tabGroups.find((group) => group.some((text) => /简介|航线图|问答/.test(text))) || + tabGroups[0] || + []; + const tabs = Array.from(new Set(tabsRaw)); + + const stageEls = Array.from( + document.querySelectorAll('.activity-line-content .week-card, .activity-left .week-card, .week-card') + ); + const stages = stageEls.map((stage) => { + const phaseTitle = clean(stage.querySelector('.title-name, .stage-name')?.textContent || ''); + const stageTitleRaw = clean(stage.querySelector('.title-week .text, .title-week, .week-title, .stage-title')?.textContent || ''); + const duration = clean( + stage.querySelector('.title-week .highlightInActivity, .duration, .time, .date-range, .stage-duration')?.textContent || '' + ); + const stageTitle = clean( + [phaseTitle, stageTitleRaw.replace(duration, '').trim()] + .map((v) => clean(v)) + .filter(Boolean) + .join(' ') + ); + const tasks = Array.from(stage.querySelectorAll('.card .row, .row')) + .map((row) => { + const key = clean(row.querySelector('.key')?.textContent || ''); + const text = clean(row.querySelector('.card-title')?.textContent || row.textContent || ''); + if (!text) return ''; + if (key && !text.startsWith(key)) return key + '. ' + text; + return text; + }) + .filter(Boolean); + return { title: stageTitle, duration, tasks }; + }).filter((stage) => stage.title || stage.tasks.length > 0); + + return { + title, + subtitle, + tabs, + stages, + url: location.href, + }; + })() + `); + if (!payload) { + throw new EmptyResultError('scys/activity', 'Failed to extract activity page content'); + } + if (!payload.title && (!payload.stages || payload.stages.length === 0)) { + throw new EmptyResultError('scys/activity', 'No activity title or stages were detected'); + } + return { + title: polishScysText(payload.title), + subtitle: polishScysText(payload.subtitle), + tabs: (payload.tabs ?? []).map((tab) => polishScysText(tab)).filter(Boolean), + stages: (payload.stages ?? []).map((stage) => ({ + title: polishScysText(stage.title), + duration: polishScysText(stage.duration), + tasks: (stage.tasks ?? []).map((task) => polishScysText(task)).filter(Boolean), + })), + url: normalizeScysUrl(payload.url || url), + }; +} diff --git a/clis/scys/extractors.test.js b/clis/scys/extractors.test.js new file mode 100644 index 000000000..3dd354055 --- /dev/null +++ b/clis/scys/extractors.test.js @@ -0,0 +1,441 @@ +import { describe, expect, it, vi } from 'vitest'; +import { extractScysArticle, extractScysFeed, extractScysOpportunity } from './extractors.js'; + +function createScysPageMock({ + loginState, + evaluateResults = [], + interceptedRequests = [], + evaluateMock, +} = {}) { + const queue = [...evaluateResults]; + return { + goto: vi.fn(async () => {}), + wait: async () => {}, + evaluate: async (js) => { + if (js.includes('const text = (document.body?.innerText ||') && js.includes('hasContentSignals')) { + return loginState ?? { + strongLoginText: false, + genericLoginText: false, + loginByDom: false, + hasContentSignals: true, + routeLooksLikeLogin: false, + loginCtaText: false, + }; + } + if (typeof evaluateMock === 'function') { + return evaluateMock(js, queue); + } + return queue.shift(); + }, + autoScroll: async () => {}, + installInterceptor: async () => {}, + getInterceptedRequests: async () => interceptedRequests, + getCookies: async () => [], + snapshot: async () => null, + click: async () => {}, + typeText: async () => {}, + pressKey: async () => {}, + scrollTo: async () => null, + getFormState: async () => null, + tabs: async () => [], + closeTab: async () => {}, + newTab: async () => {}, + selectTab: async () => {}, + networkRequests: async () => [], + consoleMessages: async () => [], + scroll: async () => {}, + waitForCapture: async () => {}, + screenshot: async () => '', + getCurrentUrl: async () => 'https://scys.com/', + }; +} + +describe('extractScysFeed', () => { + it('uses an explicit authenticated essence request instead of relying on stale tab-switch captures', async () => { + const page = createScysPageMock({ + evaluateMock: (js) => { + if (js.includes("__user_token.v3") && js.includes("isDigested")) { + return { + ok: true, + status: 200, + items: [ + { + detailUrl: null, + topicDTO: { + topicId: '22255855424524441', + entityType: 'xq_topic', + showTitle: '昨天直播的分享内容整理出来了,没想到直播了四个半小时,讲了123 万字,还是聊了挺多内容的。', + articleContent: '没来得及看直播的圈友,可以进生财有术视频号。', + gmtCreate: 1_776_145_020, + likeCount: 12, + commentsCount: 3, + favoriteCount: 4, + isDigested: true, + menuList: [{ value: '亦仁' }], + }, + topicUserDTO: { + name: '亦仁', + }, + }, + ], + }; + } + if (js.includes("window.__opencli_xhr") || js.includes("__opencli_interceptor_patched")) { + throw new Error('stale interceptor path should not run when direct essence API succeeds'); + } + return undefined; + }, + }); + + const rows = await extractScysFeed(page, 'https://scys.com/?filter=essence', { + waitSeconds: 1, + limit: 1, + maxLength: 600, + }); + + expect(rows).toEqual([ + expect.objectContaining({ + topic_id: '22255855424524441', + entity_type: 'xq_topic', + url: 'https://scys.com/articleDetail/xq_topic/22255855424524441', + flags: ['精华'], + title: '昨天直播的分享内容整理出来了,没想到直播了四个半小时,讲了123 万字,还是聊了挺多内容的。', + }), + ]); + }); + + it('keeps SCYS detail identity even when the list item also has an external source link', async () => { + const page = createScysPageMock({ + evaluateResults: [undefined, undefined], + interceptedRequests: [ + { + data: { + items: [ + { + detailUrl: 'https://my.feishu.cn/docx/PSdVdb8j3oIcIExlOsuctM4gnge?from=from_copylink', + topicDTO: { + topicId: '82255511485258522', + entityType: 'xq_topic', + externalLink: 'https://my.feishu.cn/docx/PSdVdb8j3oIcIExlOsuctM4gnge?from=from_copylink', + showTitle: '新手怎么用视频号做高客单流量?从0-1踩坑的合规指南', + articleContent: '视频号这篇辛苦大家移步飞书', + gmtCreate: 1_776_145_020, + likeCount: 12, + commentsCount: 3, + favoriteCount: 4, + menuList: [{ value: '视频号' }, { value: '项目实操' }], + }, + topicUserDTO: { + name: '些些怡', + }, + }, + ], + }, + }, + ], + }); + + const rows = await extractScysFeed(page, 'https://scys.com/?filter=essence', { + waitSeconds: 1, + limit: 1, + maxLength: 600, + }); + + expect(rows).toEqual([ + expect.objectContaining({ + topic_id: '82255511485258522', + entity_type: 'xq_topic', + url: 'https://scys.com/articleDetail/xq_topic/82255511485258522', + raw_url: 'https://scys.com/articleDetail/xq_topic/82255511485258522', + external_links: ['https://my.feishu.cn/docx/PSdVdb8j3oIcIExlOsuctM4gnge?from=from_copylink'], + source_links: ['https://my.feishu.cn/docx/PSdVdb8j3oIcIExlOsuctM4gnge?from=from_copylink'], + }), + ]); + }); +}); + +describe('extractScysOpportunity', () => { + it('uses an explicit authenticated opportunity request instead of relying on tab toggles', async () => { + const page = createScysPageMock({ + evaluateMock: (js) => { + if (js.includes("__user_token.v3") && js.includes("pageScene") && js.includes('"fxb"')) { + return { + ok: true, + status: 200, + items: [ + { + detailUrl: null, + topicDTO: { + topicId: '55522122458215244', + entityType: 'xq_topic', + showTitle: '信息差外面卖几千块的GPTPlus技术原理拆解', + articleContent: '本文仅供技术交流。', + externalLink: 'https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink', + gmtCreate: 1_776_145_020, + likeCount: 12, + commentsCount: 3, + favoriteCount: 4, + menuList: [{ value: '信息差' }, { value: 'ChatGPT' }, { value: '项目实操' }], + imageList: ['https://search01.shengcaiyoushu.com/upload/doc/Lfw7drrJKoO7dgx3nVYccY8hnAd/HRVOb2QkCoafpCxX0YIcqKOdnFd'], + }, + topicUserDTO: { + name: '阿霖', + }, + }, + ], + }; + } + if (js.includes("window.__opencli_xhr") || js.includes("__opencli_interceptor_patched")) { + throw new Error('stale interceptor path should not run when direct opportunity API succeeds'); + } + return undefined; + }, + }); + + const rows = await extractScysOpportunity(page, 'https://scys.com/opportunity', { + waitSeconds: 1, + limit: 1, + tab: '全部', + }); + + expect(rows).toEqual([ + expect.objectContaining({ + topic_id: '55522122458215244', + entity_type: 'xq_topic', + url: 'https://scys.com/articleDetail/xq_topic/55522122458215244', + raw_url: 'https://scys.com/articleDetail/xq_topic/55522122458215244', + external_links: ['https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink'], + source_links: ['https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink'], + }), + ]); + }); + + it('recovers topic identity from page cache when DOM fallback only sees an external link', async () => { + const page = createScysPageMock({ + interceptedRequests: [], + evaluateMock: (js, queue) => { + if (js.includes("__user_token.v3") && js.includes('"fxb"')) { + return { ok: false, status: 401, items: [] }; + } + if (queue.length > 0) { + return queue.shift(); + } + return undefined; + }, + evaluateResults: [ + undefined, + [ + { + author: '阿霖', + time: '1小时前', + flags: ['信息差'], + title: '信息差外面卖几千块的GPTPlus技术原理拆解', + content: '移步飞书:https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink', + ai_summary: '', + tags: ['ChatGPT', '项目实操'], + interactions: '点赞1931 评论0 收藏0', + link: 'https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink', + image_urls: [ + 'https://search01.shengcaiyoushu.com/upload/doc/Lfw7drrJKoO7dgx3nVYccY8hnAd/HRVOb2QkCoafpCxX0YIcqKOdnFd', + ], + }, + ], + [ + { + title: '外面卖几千块的GPTPlus技术原理拆解', + topic_id: '55522122458215244', + entity_type: 'xq_topic', + scys_url: 'https://scys.com/articleDetail/xq_topic/55522122458215244', + links: ['https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink'], + }, + ], + ], + }); + + const rows = await extractScysOpportunity(page, 'https://scys.com/opportunity', { + waitSeconds: 1, + limit: 1, + tab: '全部', + }); + + expect(rows).toEqual([ + expect.objectContaining({ + topic_id: '55522122458215244', + entity_type: 'xq_topic', + url: 'https://scys.com/articleDetail/xq_topic/55522122458215244', + raw_url: 'https://scys.com/articleDetail/xq_topic/55522122458215244', + external_links: ['https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink'], + source_links: ['https://flex-fox.feishu.cn/wiki/BdGkw2dqDiDBPWkkOhvcrJ7tnCe?from=from_copylink'], + }), + ]); + }); +}); + +describe('extractScysArticle', () => { + it('waits past shell placeholders and returns hydrated article content', async () => { + const page = createScysPageMock({ + evaluateResults: [ + { + entityType: 'xq_topic', + topicId: '55522122288425554', + title: '生财官网·会员主题贴', + author: '', + time: '', + flags: [], + tags: [], + content: '', + aiSummary: '', + likeText: '', + commentText: '', + favoriteText: '', + images: [], + sourceLinks: [], + externalLinks: [], + pageUrl: 'https://scys.com/articleDetail/xq_topic/55522122288425554', + }, + { + entityType: 'xq_topic', + topicId: '55522122288425554', + title: 'kikivoice.ai 这是一个免费克隆音频的网站', + author: '謃銧閃爍', + time: '2026-04-15 17:39', + flags: ['工具推荐', '风向标'], + tags: ['AI'], + content: '工具推荐 kikivoice.ai 这是一个免费克隆音频的网站', + aiSummary: '工具名称:kikivoice.ai(音频克隆网站)', + likeText: '216', + commentText: '5', + favoriteText: '0', + images: [], + sourceLinks: ['https://kikivoice.ai'], + externalLinks: ['https://kikivoice.ai'], + pageUrl: 'https://scys.com/articleDetail/xq_topic/55522122288425554', + }, + { + entityType: 'xq_topic', + topicId: '55522122288425554', + title: 'kikivoice.ai 这是一个免费克隆音频的网站', + author: '謃銧閃爍', + time: '2026-04-15 17:39', + flags: ['工具推荐', '风向标'], + tags: ['AI'], + content: '工具推荐 kikivoice.ai 这是一个免费克隆音频的网站', + aiSummary: '工具名称:kikivoice.ai(音频克隆网站)', + likeText: '216', + commentText: '5', + favoriteText: '0', + images: [], + sourceLinks: ['https://kikivoice.ai'], + externalLinks: ['https://kikivoice.ai'], + pageUrl: 'https://scys.com/articleDetail/xq_topic/55522122288425554', + }, + ], + }); + + const result = await extractScysArticle(page, 'https://scys.com/articleDetail/xq_topic/55522122288425554', { + waitSeconds: 1, + maxLength: 4000, + }); + + expect(result).toMatchObject({ + topic_id: '55522122288425554', + title: 'kikivoice.ai 这是一个免费克隆音频的网站', + author: '謃銧閃爍', + content: '工具推荐 kikivoice.ai 这是一个免费克隆音频的网站', + external_links: ['https://kikivoice.ai'], + source_links: ['https://kikivoice.ai'], + }); + }); + + it('re-navigates once when the article stays on shell content and then succeeds', async () => { + const page = createScysPageMock({ + evaluateResults: [ + { + entityType: 'xq_topic', + topicId: '14422288551185512', + title: '生财官网·会员主题贴', + author: '', + time: '', + flags: [], + tags: [], + content: '', + aiSummary: '', + likeText: '', + commentText: '', + favoriteText: '', + images: [], + sourceLinks: [], + externalLinks: [], + pageUrl: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + }, + { + entityType: 'xq_topic', + topicId: '14422288551185512', + title: '生财官网·会员主题贴', + author: '', + time: '', + flags: [], + tags: [], + content: '', + aiSummary: '', + likeText: '', + commentText: '', + favoriteText: '', + images: [], + sourceLinks: [], + externalLinks: [], + pageUrl: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + }, + { + entityType: 'xq_topic', + topicId: '14422288551185512', + title: '生财官网·会员主题贴', + author: '', + time: '', + flags: [], + tags: [], + content: '', + aiSummary: '', + likeText: '', + commentText: '', + favoriteText: '', + images: [], + sourceLinks: [], + externalLinks: [], + pageUrl: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + }, + { + entityType: 'xq_topic', + topicId: '14422288551185512', + title: 'Youtube复盘:从5个月颗粒无收到3个月开通3个高级YPP,1.7亿播放', + author: '加一', + time: '2026-04-17 12:34', + flags: ['项目实操'], + tags: ['YouTube'], + content: '这是一次 Youtube 复盘。', + aiSummary: '一次关于 YouTube 变现的复盘总结。', + likeText: '88', + commentText: '12', + favoriteText: '6', + images: [], + sourceLinks: [], + externalLinks: [], + pageUrl: 'https://scys.com/articleDetail/xq_topic/14422288551185512', + }, + ], + }); + + const result = await extractScysArticle(page, 'https://scys.com/articleDetail/xq_topic/14422288551185512', { + waitSeconds: 1, + maxLength: 4000, + }); + + expect(page.goto).toHaveBeenCalledTimes(3); + expect(result).toMatchObject({ + topic_id: '14422288551185512', + title: 'Youtube复盘:从5个月颗粒无收到3个月开通3个高级YPP,1.7亿播放', + author: '加一', + content: '这是一次 Youtube 复盘。', + }); + }); +}); diff --git a/clis/scys/extractors.toc.test.js b/clis/scys/extractors.toc.test.js new file mode 100644 index 000000000..f905159bd --- /dev/null +++ b/clis/scys/extractors.toc.test.js @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { extractScysToc } from './extractors.js'; +function createScysTocPageMock(loginState, tocRows) { + return { + goto: async () => { }, + wait: async () => { }, + evaluate: async (js) => { + if (js.includes('const text = (document.body?.innerText ||') && js.includes('hasContentSignals')) { + return loginState ?? { + strongLoginText: false, + genericLoginText: false, + loginByDom: false, + hasContentSignals: true, + routeLooksLikeLogin: false, + }; + } + if (js.includes('sectionTitleEl.click') || js.includes('sectionTitleEl.dispatchEvent')) { + return [ + { entry_type: 'section', section: '预备篇', group: '预备篇', chapter_id: '', chapter_title: '预备篇', status: '', is_current: false }, + { entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4137', chapter_title: '课程前言', status: '737人学过', is_current: false }, + { entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4038', chapter_title: '课程目标', status: '508人学过', is_current: true }, + { entry_type: 'section', section: '基础篇', group: '基础篇', chapter_id: '', chapter_title: '基础篇', status: '', is_current: false }, + { entry_type: 'chapter', section: '基础篇', group: '一、玩起来! 通过 AI,10 分钟发布你的第一款网站产品!', chapter_id: '4039', chapter_title: '视频', status: '624人学过', is_current: false }, + { entry_type: 'chapter', section: '基础篇', group: '一、玩起来! 通过 AI,10 分钟发布你的第一款网站产品!', chapter_id: '4040', chapter_title: '图文', status: '674人学过', is_current: false }, + ]; + } + return tocRows ?? [ + { entry_type: 'section', section: '预备篇', group: '预备篇', chapter_id: '', chapter_title: '预备篇', status: '', is_current: false }, + { entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4137', chapter_title: '课程前言', status: '737人学过', is_current: false }, + { entry_type: 'chapter', section: '预备篇', group: '图文', chapter_id: '4038', chapter_title: '课程目标', status: '508人学过', is_current: true }, + { entry_type: 'section', section: '基础篇', group: '基础篇', chapter_id: '', chapter_title: '基础篇', status: '', is_current: false }, + ]; + }, + getCookies: async () => [], + snapshot: async () => null, + click: async () => { }, + typeText: async () => { }, + pressKey: async () => { }, + scrollTo: async () => null, + getFormState: async () => null, + tabs: async () => [], + closeTab: async () => { }, + newTab: async () => { }, + selectTab: async () => { }, + networkRequests: async () => [], + consoleMessages: async () => [], + scroll: async () => { }, + autoScroll: async () => { }, + installInterceptor: async () => { }, + getInterceptedRequests: async () => [], + waitForCapture: async () => { }, + screenshot: async () => '', + getCurrentUrl: async () => 'https://scys.com/course/detail/92', + }; +} +describe('extractScysToc', () => { + it('expands collapsed sections to recover deterministic chapter ids', async () => { + const page = createScysTocPageMock(); + const rows = await extractScysToc(page, '92', { waitSeconds: 1 }); + expect(rows.some((row) => row.chapter_id === '4039')).toBe(true); + expect(rows.some((row) => row.chapter_id === '4040')).toBe(true); + expect(rows.find((row) => row.chapter_id === '4039')?.group).toBe('一、玩起来! 通过 AI,10 分钟发布你的第一款网站产品!'); + }); + it('treats login CTA walls as auth failures instead of outdated adapter errors', async () => { + const page = createScysTocPageMock({ + strongLoginText: false, + genericLoginText: false, + loginByDom: false, + hasContentSignals: false, + routeLooksLikeLogin: false, + loginCtaText: true, + }, []); + await expect(extractScysToc(page, '92', { waitSeconds: 1 })).rejects.toThrow('SCYS content requires a logged-in browser session'); + }); +}); diff --git a/clis/scys/feed.js b/clis/scys/feed.js new file mode 100644 index 000000000..e8327a3a8 --- /dev/null +++ b/clis/scys/feed.js @@ -0,0 +1,23 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildScysHomeEssenceUrl } from './common.js'; +import { extractScysFeed } from './extractors.js'; +cli({ + site: 'scys', + name: 'feed', + description: 'Extract SCYS feed cards (home essence or profile posts)', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'url', positional: true, default: buildScysHomeEssenceUrl(), help: 'Feed URL (default: home essence feed)' }, + { name: 'limit', type: 'int', default: 20, help: 'Max number of cards' }, + { name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' }, + ], + columns: ['rank', 'author', 'time', 'flags', 'title', 'summary', 'tags', 'interactions_display', 'image_count', 'url'], + func: async (page, kwargs) => { + return extractScysFeed(page, String(kwargs.url ?? buildScysHomeEssenceUrl()), { + waitSeconds: Number(kwargs.wait ?? 3), + limit: Number(kwargs.limit ?? 20), + }); + }, +}); diff --git a/clis/scys/opportunity-utils.js b/clis/scys/opportunity-utils.js new file mode 100644 index 000000000..800742738 --- /dev/null +++ b/clis/scys/opportunity-utils.js @@ -0,0 +1,88 @@ +import { ArgumentError } from '@jackwener/opencli/errors'; +const FLAG_SET = new Set(['中标', '热门', '信息差', '新玩法', '市场洞察', '风向标']); +export function normalizeOpportunityTab(input) { + const raw = String(input ?? '').trim().toLowerCase(); + if (!raw || raw === 'all' || raw === '全部') + return { key: 'all', label: '全部' }; + if (raw === 'hot' || raw === '热门') + return { key: 'hot', label: '热门' }; + if (raw === 'winning' || raw === 'win' || raw === 'zhongbiao' || raw === '中标') { + return { key: 'winning', label: '中标' }; + } + throw new ArgumentError(`Unsupported tab: ${String(input)}`, 'Use one of: all/全部, hot/热门, winning/中标'); +} +export function splitOpportunityFlagsAndTags(values) { + const cleaned = values.map((v) => String(v || '').trim()).filter(Boolean); + const flags = Array.from(new Set(cleaned.filter((v) => FLAG_SET.has(v)))); + const tags = Array.from(new Set(cleaned.filter((v) => !FLAG_SET.has(v)))); + return { flags, tags }; +} +export function buildScysTopicLink(entityType, entityId) { + const type = String(entityType ?? '').trim(); + const id = String(entityId ?? '').trim(); + if (!type || !id) + return ''; + return `https://scys.com/articleDetail/${encodeURIComponent(type)}/${encodeURIComponent(id)}`; +} +export function inferTopicIdFromImageUrls(urls) { + if (!Array.isArray(urls)) + return ''; + for (const raw of urls) { + const text = String(raw || ''); + const m = text.match(/\/images\/(\d{8,})\//); + if (m?.[1]) + return m[1]; + } + return ''; +} +export function parseAiSummaryText(input) { + return stripScysRichText(input); +} +function decodeUriSafe(value) { + try { + return decodeURIComponent(value); + } + catch { + return value; + } +} +/** + * SCYS 富文本常见格式: + * - + * - 常规 HTML 标签

//... + */ +export function stripScysRichText(input) { + const raw = String(input ?? ''); + if (!raw) + return ''; + const withHashtagText = raw.replace(/]*\btitle="([^"]+)"[^>]*\/?>/gi, (_full, title) => ` ${decodeUriSafe(title)} `); + return withHashtagText + .replace(/]*\/?>/gi, ' ') + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/\s+/g, ' ') + .trim(); +} +export function formatScysRelativeTime(tsSeconds, nowMs = Date.now()) { + const ts = Number(tsSeconds); + if (!Number.isFinite(ts) || ts <= 0) + return ''; + const targetMs = ts * 1000; + const deltaSec = Math.floor((nowMs - targetMs) / 1000); + if (deltaSec < 0) + return ''; + if (deltaSec < 60) + return '刚刚'; + if (deltaSec < 3600) + return `${Math.max(1, Math.floor(deltaSec / 60))}分钟前`; + if (deltaSec < 86400) + return `${Math.max(1, Math.floor(deltaSec / 3600))}小时前`; + if (deltaSec < 86400 * 30) + return `${Math.max(1, Math.floor(deltaSec / 86400))}天前`; + const d = new Date(targetMs); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} diff --git a/clis/scys/opportunity-utils.test.js b/clis/scys/opportunity-utils.test.js new file mode 100644 index 000000000..7dcc212e2 --- /dev/null +++ b/clis/scys/opportunity-utils.test.js @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { buildScysTopicLink, formatScysRelativeTime, inferTopicIdFromImageUrls, normalizeOpportunityTab, parseAiSummaryText, stripScysRichText, splitOpportunityFlagsAndTags, } from './opportunity-utils.js'; +describe('normalizeOpportunityTab', () => { + it('maps all aliases', () => { + expect(normalizeOpportunityTab('')).toEqual({ key: 'all', label: '全部' }); + expect(normalizeOpportunityTab('all')).toEqual({ key: 'all', label: '全部' }); + expect(normalizeOpportunityTab('全部')).toEqual({ key: 'all', label: '全部' }); + }); + it('maps hot aliases', () => { + expect(normalizeOpportunityTab('hot')).toEqual({ key: 'hot', label: '热门' }); + expect(normalizeOpportunityTab('热门')).toEqual({ key: 'hot', label: '热门' }); + }); + it('maps winning aliases', () => { + expect(normalizeOpportunityTab('winning')).toEqual({ key: 'winning', label: '中标' }); + expect(normalizeOpportunityTab('win')).toEqual({ key: 'winning', label: '中标' }); + expect(normalizeOpportunityTab('中标')).toEqual({ key: 'winning', label: '中标' }); + }); +}); +describe('splitOpportunityFlagsAndTags', () => { + it('splits system flags and custom tags', () => { + expect(splitOpportunityFlagsAndTags(['中标', '市场洞察', '垂直小号', '00后/大学生'])).toEqual({ + flags: ['中标', '市场洞察'], + tags: ['垂直小号', '00后/大学生'], + }); + }); +}); +describe('buildScysTopicLink', () => { + it('builds canonical article detail link', () => { + expect(buildScysTopicLink('xq_topic', '45811252552251118')).toBe('https://scys.com/articleDetail/xq_topic/45811252552251118'); + }); +}); +describe('inferTopicIdFromImageUrls', () => { + it('extracts topic id from signed oss image urls', () => { + expect(inferTopicIdFromImageUrls([ + 'https://sphere-sh.oss-cn-shanghai.aliyuncs.com/private/xq/images/45811252552251118/Fmrm4.jpg?Expires=1', + ])).toBe('45811252552251118'); + }); +}); +describe('parseAiSummaryText', () => { + it('strips html tags', () => { + expect(parseAiSummaryText('

细分需求:测试

')).toBe('细分需求: 测试'); + }); +}); +describe('stripScysRichText', () => { + it('converts SCYS hashtag marker and strips tags', () => { + expect(stripScysRichText('蹭热度:

备考

')).toBe('蹭热度: #全国计算机考试# 备考'); + }); +}); +describe('formatScysRelativeTime', () => { + const now = new Date('2026-03-28T12:00:00Z').getTime(); + it('formats recent intervals', () => { + expect(formatScysRelativeTime(Math.floor((now - 30_000) / 1000), now)).toBe('刚刚'); + expect(formatScysRelativeTime(Math.floor((now - 10 * 60_000) / 1000), now)).toBe('10分钟前'); + expect(formatScysRelativeTime(Math.floor((now - 3 * 3600_000) / 1000), now)).toBe('3小时前'); + expect(formatScysRelativeTime(Math.floor((now - 5 * 86400_000) / 1000), now)).toBe('5天前'); + }); + it('falls back to absolute date for old timestamps', () => { + expect(formatScysRelativeTime(Math.floor((now - 40 * 86400_000) / 1000), now)).toBe('2026-02-16'); + }); +}); diff --git a/clis/scys/opportunity.js b/clis/scys/opportunity.js new file mode 100644 index 000000000..0115955a3 --- /dev/null +++ b/clis/scys/opportunity.js @@ -0,0 +1,67 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildScysOpportunityUrl } from './common.js'; +import { extractScysOpportunity } from './extractors.js'; +import { normalizeOpportunityTab } from './opportunity-utils.js'; +import { formatCookieHeader } from '@jackwener/opencli/download'; +import { downloadMedia } from '@jackwener/opencli/download/media-download'; +import * as path from 'node:path'; +cli({ + site: 'scys', + name: 'opportunity', + description: 'Extract SCYS opportunity feed with flags, summaries, and tags', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'url', positional: true, default: buildScysOpportunityUrl(), help: 'Opportunity URL' }, + { name: 'tab', default: 'all', help: 'Filter tab: all/全部, hot/热门, winning/中标' }, + { name: 'limit', type: 'int', default: 20, help: 'Max number of cards' }, + { name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' }, + { name: 'download-images', type: 'boolean', default: false, help: 'Download post images to local directory' }, + { name: 'output', default: './scys-opportunity-downloads', help: 'Image output directory' }, + ], + columns: ['rank', 'author', 'time', 'flags', 'title', 'summary', 'ai_summary', 'tags', 'interactions_display', 'image_count', 'url', 'image_dir'], + func: async (page, kwargs) => { + const tab = normalizeOpportunityTab(kwargs.tab); + const rows = await extractScysOpportunity(page, String(kwargs.url ?? buildScysOpportunityUrl()), { + waitSeconds: Number(kwargs.wait ?? 3), + limit: Number(kwargs.limit ?? 20), + tab: tab.label, + }); + const downloadImages = kwargs['download-images'] === true || String(kwargs['download-images']) === 'true'; + if (!downloadImages) + return rows; + const output = String(kwargs.output ?? './scys-opportunity-downloads'); + const cookies = formatCookieHeader(await page.getCookies({ domain: 'scys.com' })); + const withDownloads = []; + for (const row of rows) { + const imageUrls = Array.isArray(row.images) ? row.images.filter(Boolean) : []; + if (imageUrls.length === 0) { + withDownloads.push({ ...row, image_count: 0, image_dir: '' }); + continue; + } + const topicId = row.topic_id || `opportunity_${row.rank}`; + const subdir = path.join(tab.label, topicId); + const media = imageUrls.map((url, idx) => ({ + type: 'image', + url, + filename: `${topicId}_${idx + 1}.jpg`, + })); + const results = await downloadMedia(media, { + output, + subdir, + cookies, + filenamePrefix: topicId, + timeout: 60_000, + verbose: false, + }); + const successCount = results.filter((r) => r.status === 'success').length; + withDownloads.push({ + ...row, + image_count: successCount, + image_dir: path.join(output, subdir), + }); + } + return withDownloads; + }, +}); diff --git a/clis/scys/read.js b/clis/scys/read.js new file mode 100644 index 000000000..595c4b75d --- /dev/null +++ b/clis/scys/read.js @@ -0,0 +1,57 @@ +import { ArgumentError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { downloadScysCourseImages } from './course-download.js'; +import { detectScysPageType, inferScysReadUrl } from './common.js'; +import { extractScysActivity, extractScysArticle, extractScysCourse, extractScysCourseAll, extractScysFeed, extractScysOpportunity, } from './extractors.js'; +cli({ + site: 'scys', + name: 'read', + description: 'Read a SCYS page with automatic page-type routing', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'url', required: true, positional: true, help: 'Any scys.com URL' }, + { name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' }, + { name: 'limit', type: 'int', default: 20, help: 'Max rows for list pages' }, + { name: 'max-length', type: 'int', default: 4000, help: 'Max content length for long text fields' }, + { name: 'all', type: 'boolean', default: false, help: 'For course pages, export all deterministic chapter ids from TOC' }, + { name: 'download-images', type: 'boolean', default: false, help: 'For course pages, download page images to local directory' }, + { name: 'output', default: './scys-course-downloads', help: 'Image output directory for course pages' }, + ], + func: async (page, kwargs) => { + const url = inferScysReadUrl(String(kwargs.url)); + const waitSeconds = Math.max(1, Number(kwargs.wait ?? 3)); + const limit = Math.max(1, Number(kwargs.limit ?? 20)); + const maxLength = Math.max(300, Number(kwargs['max-length'] ?? 4000)); + const all = kwargs.all === true || String(kwargs.all) === 'true'; + const downloadImages = kwargs['download-images'] === true || String(kwargs['download-images']) === 'true'; + const pageType = detectScysPageType(url); + if (pageType === 'course') { + const extracted = all + ? await extractScysCourseAll(page, url, { waitSeconds, maxLength }) + : await extractScysCourse(page, url, { waitSeconds, maxLength }); + const data = downloadImages + ? await downloadScysCourseImages(page, extracted, String(kwargs.output ?? './scys-course-downloads')) + : extracted; + return { page_type: pageType, data }; + } + if (pageType === 'feed') { + const data = await extractScysFeed(page, url, { waitSeconds, limit, maxLength }); + return { page_type: pageType, data }; + } + if (pageType === 'opportunity') { + const data = await extractScysOpportunity(page, url, { waitSeconds, limit, maxLength }); + return { page_type: pageType, data }; + } + if (pageType === 'activity') { + const data = await extractScysActivity(page, url, { waitSeconds, maxLength }); + return { page_type: pageType, data }; + } + if (pageType === 'article') { + const data = await extractScysArticle(page, url, { waitSeconds, maxLength }); + return { page_type: pageType, data }; + } + throw new ArgumentError(`Unsupported SCYS page for scys/read: ${url}`, 'Supported patterns: /course/detail/:id, /?filter=essence, /personal/:id?tab=posts, /opportunity, /activity/landing/:id, /articleDetail/:entityType/:topicId'); + }, +}); diff --git a/clis/scys/toc.js b/clis/scys/toc.js new file mode 100644 index 000000000..2a189bf49 --- /dev/null +++ b/clis/scys/toc.js @@ -0,0 +1,20 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractScysToc } from './extractors.js'; +cli({ + site: 'scys', + name: 'toc', + description: 'Extract chapter table of contents from a SCYS course', + domain: 'scys.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'course', required: true, positional: true, help: 'Course URL or numeric course id' }, + { name: 'wait', type: 'int', default: 2, help: 'Seconds to wait after page load' }, + ], + columns: ['rank', 'entry_type', 'section', 'group', 'chapter_id', 'chapter_title', 'status', 'is_current'], + func: async (page, kwargs) => { + return extractScysToc(page, String(kwargs.course), { + waitSeconds: Number(kwargs.wait ?? 2), + }); + }, +}); diff --git a/docs/adapters/browser/scys.md b/docs/adapters/browser/scys.md new file mode 100644 index 000000000..ecb787f3e --- /dev/null +++ b/docs/adapters/browser/scys.md @@ -0,0 +1,55 @@ +# SCYS + +**Mode**: 🔐 Browser · **Domain**: `scys.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli scys course ` | Read SCYS course detail content and chapter context | +| `opencli scys toc ` | Extract the chapter outline from a SCYS course detail page | +| `opencli scys read ` | Auto-detect the SCYS page type and dispatch to the right extractor | +| `opencli scys feed [url]` | Read SCYS 精华 feed cards with summaries and interactions | +| `opencli scys opportunity [url]` | Read SCYS opportunity cards with AI summaries and tags | +| `opencli scys activity ` | Read a SCYS activity landing page timeline | +| `opencli scys article ` | Read a SCYS article detail page | + +## Usage Examples + +```bash +# Read one course chapter +opencli scys course "https://scys.com/course/detail/92" + +# Export all deterministic chapters from the TOC +opencli scys course "https://scys.com/course/detail/92" --all -f json + +# Download course images while exporting all chapters +opencli scys course "https://scys.com/course/detail/92" --all --download-images --output ./scys-course-downloads -f json + +# Extract just the table of contents +opencli scys toc "https://scys.com/course/detail/92" -f json + +# Read the essence feed +opencli scys feed "https://scys.com/?filter=essence" -f json + +# Read the opportunity page +opencli scys opportunity "https://scys.com/opportunity" -f json + +# Read an article detail page +opencli scys article "https://scys.com/articleDetail/xq_topic/55188458224514554" -f json + +# Let read auto-dispatch based on URL +opencli scys read "https://scys.com/articleDetail/xq_topic/55188458224514554" -f json +``` + +## Prerequisites + +- Chrome logged into `scys.com` +- [Browser Bridge extension](/guide/browser-bridge) installed + +## Notes + +- `read` dispatches by URL shape and supports course, feed, opportunity, activity, and article pages +- `course --all` expands deterministic chapter IDs from the page TOC and exports each chapter as a separate row +- `course --download-images` stores course images under the output directory and adds `image_dir`/`image_count` fields to the result +- `article`, `feed`, and `opportunity` normalize output to stable JSON field names such as `url`, `raw_url`, `summary`, `content`, and structured `interactions` diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 6f015c61c..6fc4eda0e 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -14,6 +14,7 @@ Run `opencli list` for the live registry. | **[zhihu](./browser/zhihu.md)** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 🔐 Browser | | **[xiaohongshu](./browser/xiaohongshu.md)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser | | **[xiaoe](./browser/xiaoe.md)** | `courses` `detail` `catalog` `play-url` `content` | 🔐 Browser | +| **[scys](./browser/scys.md)** | `course` `toc` `read` `feed` `opportunity` `activity` `article` | 🔐 Browser | | **[xueqiu](./browser/xueqiu.md)** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser | | **[youtube](./browser/youtube.md)** | `search` `video` `transcript` `comments` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | 🔐 Browser | | **[v2ex](./browser/v2ex.md)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 | diff --git a/docs/developer/scys-schema-guidelines.md b/docs/developer/scys-schema-guidelines.md new file mode 100644 index 000000000..83d67f9ed --- /dev/null +++ b/docs/developer/scys-schema-guidelines.md @@ -0,0 +1,59 @@ +# SCYS Schema Guidelines + +This document defines JSON output conventions for `opencli scys` commands to keep `feed`, `opportunity`, `article`, and `read` consistent for pipeline consumers. + +## 1) Canonical Naming + +Use these canonical field names for the same semantics: + +- `url`: canonical page/detail URL +- `raw_url`: original URL used to fetch data (before normalization/fallback) +- `images`: image URL list (`string[]`) +- `summary`: list/card preview text +- `content`: full detail text (detail pages) + +Deprecated aliases (`link`, `raw_link`, `image_urls`, `preview`) are no longer part of canonical SCYS JSON output. New code must not reintroduce them. + +## 2) Canonical Types + +For all SCYS JSON outputs: + +- `tags`: always `string[]` +- `flags`: always `string[]` +- `images`: always `string[]` +- `external_links`: always `string[]` +- `source_links`: always `string[]` +- `interactions`: always object: + +```json +{ + "likes": 16, + "comments": 0, + "favorites": 4, + "display": "点赞16 评论0 收藏4" +} +``` + +## 3) Structured vs Display Fields + +Keep machine fields and display fields separate: + +- Machine fields: `interactions.likes/comments/favorites`, `tags`, `flags`, `images` +- Display field: `interactions.display` +- Table-oriented helper fields (for CLI table only), e.g. `interactions_display`, are allowed but should mirror structured fields exactly. + +## 4) List vs Detail Semantics + +- List commands (`feed`, `opportunity`) should prioritize `summary`. +- Detail command (`article`) should prioritize `content`. +- Do not expose duplicated legacy aliases in normal command output. + +## 5) Change Checklist + +When adding or changing SCYS commands: + +1. Reuse canonical field names and types from this document. +2. Do not add new semantic duplicates. +3. Keep `scys read` routing output schema aligned with direct command output. +4. Run `npm run typecheck` and adapter tests before commit. +5. If compatibility aliases are changed or removed, document it in PR notes explicitly. diff --git a/extension/dist/background.js b/extension/dist/background.js index 7b30a6d53..4fbd472cf 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -11,6 +11,30 @@ function isDebuggableUrl$1(url) { if (!url) return true; return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } +function isRetryableDebuggerErrorMessage(message) { + return message.includes("Inspected target navigated") || message.includes("Target closed") || message.includes("attach failed") || message.includes("Debugger is not attached") || message.includes("Detached while handling command") || message.includes("chrome-extension://"); +} +function retryDelayMsForDebuggerError(message) { + return message.includes("Inspected target navigated") || message.includes("Target closed") ? 200 : 500; +} +async function sendCommandWithRetry(tabId, method, params = {}, aggressiveRetry = false) { + const maxRetries = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await ensureAttached(tabId, aggressiveRetry); + return await chrome.debugger.sendCommand({ tabId }, method, params); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (isRetryableDebuggerErrorMessage(msg) && attempt < maxRetries) { + attached.delete(tabId); + await new Promise((resolve) => setTimeout(resolve, retryDelayMsForDebuggerError(msg))); + continue; + } + throw e; + } + } + throw new Error(`CDP command ${method} failed after retries`); +} async function ensureAttached(tabId, aggressiveRetry = false) { try { const tab = await chrome.tabs.get(tabId); @@ -83,44 +107,25 @@ async function ensureAttached(tabId, aggressiveRetry = false) { } } async function evaluate(tabId, expression, aggressiveRetry = false) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } + const result = await sendCommandWithRetry(tabId, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }, aggressiveRetry); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); } - throw new Error("evaluate: max retries exhausted"); + return result.result?.value; } const evaluateAsync = evaluate; async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); const format = options.format ?? "png"; if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const metrics = await sendCommandWithRetry(tabId, "Page.getLayoutMetrics"); const size = metrics.cssContentSize || metrics.contentSize; if (size) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + await sendCommandWithRetry(tabId, "Emulation.setDeviceMetricsOverride", { mobile: false, width: Math.ceil(size.width), height: Math.ceil(size.height), @@ -133,35 +138,33 @@ async function screenshot(tabId, options = {}) { if (format === "jpeg" && options.quality !== void 0) { params.quality = Math.max(0, Math.min(100, options.quality)); } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); + const result = await sendCommandWithRetry(tabId, "Page.captureScreenshot", params); return result.data; } finally { if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { + await sendCommandWithRetry(tabId, "Emulation.clearDeviceMetricsOverride").catch(() => { }); } } } async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + await sendCommandWithRetry(tabId, "DOM.enable"); + const doc = await sendCommandWithRetry(tabId, "DOM.getDocument"); const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + const result = await sendCommandWithRetry(tabId, "DOM.querySelector", { nodeId: doc.root.nodeId, selector: query }); if (!result.nodeId) { throw new Error(`No element found matching selector: ${query}`); } - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + await sendCommandWithRetry(tabId, "DOM.setFileInputFiles", { files, nodeId: result.nodeId }); } async function insertText(tabId, text) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); + await sendCommandWithRetry(tabId, "Input.insertText", { text }); } function normalizeCapturePatterns(pattern) { return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); @@ -200,8 +203,7 @@ function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { return entry; } async function startNetworkCapture(tabId, pattern) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + await sendCommandWithRetry(tabId, "Network.enable"); networkCaptures.set(tabId, { patterns: normalizeCapturePatterns(pattern), entries: [], @@ -249,9 +251,10 @@ function registerListeners() { if (!tabId) return; const state = networkCaptures.get(tabId); if (!state) return; + const eventParams = params ?? {}; if (method === "Network.requestWillBeSent") { - const requestId = String(params?.requestId || ""); - const request = params?.request; + const requestId = String(eventParams.requestId || ""); + const request = eventParams.request; const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: request?.url, method: request?.method, @@ -271,8 +274,8 @@ function registerListeners() { return; } if (method === "Network.responseReceived") { - const requestId = String(params?.requestId || ""); - const response = params?.response; + const requestId = String(eventParams.requestId || ""); + const response = eventParams.response; const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); @@ -283,7 +286,7 @@ function registerListeners() { return; } if (method === "Network.loadingFinished") { - const requestId = String(params?.requestId || ""); + const requestId = String(eventParams.requestId || ""); const stateEntryIndex = state.requestToIndex.get(requestId); if (stateEntryIndex === void 0) return; const entry = state.entries[stateEntryIndex]; @@ -648,7 +651,7 @@ function setWorkspaceSession(workspace, session) { } async function resolveCommandTabId(cmd) { if (cmd.page) return resolveTabId$1(cmd.page); - return cmd.tabId; + return void 0; } async function resolveTab(tabId, workspace, initialUrl) { if (tabId !== void 0) { @@ -678,7 +681,11 @@ async function resolveTab(tabId, workspace, initialUrl) { const existingSession = automationSessions.get(workspace); if (existingSession?.preferredTabId !== null) { try { - const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + const preferredTabId = existingSession?.preferredTabId; + if (preferredTabId === null || preferredTabId === void 0) { + throw new Error("Preferred tab is unavailable"); + } + const preferredTab = await chrome.tabs.get(preferredTabId); if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; } catch { automationSessions.delete(workspace); @@ -855,7 +862,7 @@ async function handleTabs(cmd, workspace) { return { id: cmd.id, ok: true, data: { closed: closedPage } }; } case "select": { - if (cmd.index === void 0 && cmd.page === void 0 && cmd.tabId === void 0) + if (cmd.index === void 0 && cmd.page === void 0) return { id: cmd.id, ok: false, error: "Missing index or page" }; const cmdTabId = await resolveCommandTabId(cmd); if (cmdTabId !== void 0) { @@ -1053,3 +1060,4 @@ async function handleBindCurrent(cmd, workspace) { workspace }); } +initialize(); diff --git a/extension/package-lock.json b/extension/package-lock.json index 2288e01cf..94ede7fa8 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencli-extension", - "version": "1.5.5", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencli-extension", - "version": "1.5.5", + "version": "1.0.0", "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", @@ -15,7 +15,7 @@ }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" @@ -32,7 +32,7 @@ }, "node_modules/@esbuild/android-arm": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" @@ -49,7 +49,7 @@ }, "node_modules/@esbuild/android-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" @@ -66,7 +66,7 @@ }, "node_modules/@esbuild/android-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" @@ -83,7 +83,7 @@ }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" @@ -100,7 +100,7 @@ }, "node_modules/@esbuild/darwin-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" @@ -117,7 +117,7 @@ }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" @@ -134,7 +134,7 @@ }, "node_modules/@esbuild/freebsd-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" @@ -151,7 +151,7 @@ }, "node_modules/@esbuild/linux-arm": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" @@ -168,7 +168,7 @@ }, "node_modules/@esbuild/linux-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" @@ -185,7 +185,7 @@ }, "node_modules/@esbuild/linux-ia32": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" @@ -202,7 +202,7 @@ }, "node_modules/@esbuild/linux-loong64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" @@ -219,7 +219,7 @@ }, "node_modules/@esbuild/linux-mips64el": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" @@ -236,7 +236,7 @@ }, "node_modules/@esbuild/linux-ppc64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" @@ -253,7 +253,7 @@ }, "node_modules/@esbuild/linux-riscv64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" @@ -270,7 +270,7 @@ }, "node_modules/@esbuild/linux-s390x": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" @@ -287,7 +287,7 @@ }, "node_modules/@esbuild/linux-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" @@ -304,7 +304,7 @@ }, "node_modules/@esbuild/netbsd-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" @@ -321,7 +321,7 @@ }, "node_modules/@esbuild/netbsd-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" @@ -338,7 +338,7 @@ }, "node_modules/@esbuild/openbsd-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" @@ -355,7 +355,7 @@ }, "node_modules/@esbuild/openbsd-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" @@ -372,7 +372,7 @@ }, "node_modules/@esbuild/openharmony-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" @@ -389,7 +389,7 @@ }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" @@ -406,7 +406,7 @@ }, "node_modules/@esbuild/win32-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" @@ -423,7 +423,7 @@ }, "node_modules/@esbuild/win32-ia32": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" @@ -440,7 +440,7 @@ }, "node_modules/@esbuild/win32-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" @@ -456,9 +456,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -470,9 +470,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -484,9 +484,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -512,9 +512,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -526,9 +526,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -540,13 +540,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -554,13 +557,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -568,13 +574,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -582,13 +591,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -596,13 +608,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -610,13 +625,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -624,13 +642,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -638,13 +659,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -652,13 +676,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -666,13 +693,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -680,13 +710,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -694,13 +727,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -708,13 +744,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -722,9 +761,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -736,9 +775,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -750,9 +789,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -764,9 +803,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -778,9 +817,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -792,9 +831,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -807,7 +846,7 @@ }, "node_modules/@types/chrome": { "version": "0.0.287", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "resolved": "https://registry.npmmirror.com/@types/chrome/-/chrome-0.0.287.tgz", "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", "dev": true, "license": "MIT", @@ -818,14 +857,14 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/filesystem": { "version": "0.0.36", - "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "resolved": "https://registry.npmmirror.com/@types/filesystem/-/filesystem-0.0.36.tgz", "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", "dev": true, "license": "MIT", @@ -835,21 +874,21 @@ }, "node_modules/@types/filewriter": { "version": "0.0.33", - "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "resolved": "https://registry.npmmirror.com/@types/filewriter/-/filewriter-0.0.33.tgz", "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", "dev": true, "license": "MIT" }, "node_modules/@types/har-format": { "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "resolved": "https://registry.npmmirror.com/@types/har-format/-/har-format-1.2.16.tgz", "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, @@ -891,7 +930,7 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", @@ -909,7 +948,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, @@ -924,7 +963,7 @@ }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ @@ -943,18 +982,17 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -963,9 +1001,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -992,9 +1030,9 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1008,37 +1046,37 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", @@ -1047,14 +1085,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1065,7 +1103,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", @@ -1078,9 +1116,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 54543d7c5..f5258138f 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -type Listener void> = { addListener: (fn: T) => void }; +type Listener void> = { + addListener: ((fn: T) => void) | ReturnType; + removeListener?: ((fn: T) => void) | ReturnType; +}; type MockTab = { id: number; @@ -427,7 +430,7 @@ describe('background tab isolation', () => { expect(mod.__test__.getIdleTimeout('browser:manual')).toBe(180_000); // Simulate user closing the window — invoke the onRemoved listener - const onRemovedListener = chrome.windows.onRemoved.addListener.mock.calls[0][0]; + const onRemovedListener = (chrome.windows.onRemoved.addListener as ReturnType).mock.calls[0][0]; await onRemovedListener(42); // Session and override should both be cleaned up diff --git a/extension/src/background.ts b/extension/src/background.ts index d4fe12d77..6fed05683 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -464,7 +464,11 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU const existingSession = automationSessions.get(workspace); if (existingSession?.preferredTabId !== null) { try { - const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + const preferredTabId = existingSession?.preferredTabId; + if (preferredTabId === null || preferredTabId === undefined) { + throw new Error('Preferred tab is unavailable'); + } + const preferredTab = await chrome.tabs.get(preferredTabId); if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab }; } catch { automationSessions.delete(workspace); @@ -933,3 +937,8 @@ export const __test__ = { setWorkspaceSession(workspace, session); }, }; + +// MV3 service workers can be spun up outside install/startup events. +// Initialize eagerly on module load so a freshly loaded unpacked extension +// still connects to the daemon and registers listeners immediately. +initialize(); diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index d1424a333..a4e61215e 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -58,6 +58,29 @@ describe('cdp attach recovery', () => { expect(scripting.executeScript).not.toHaveBeenCalled(); }); + it('re-attaches and retries when a command detaches mid-flight', async () => { + const { chrome, debuggerApi } = createChromeMock(); + let detachedOnce = false; + debuggerApi.sendCommand.mockImplementation(async (_target: unknown, method: string) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (!detachedOnce) { + detachedOnce = true; + throw new Error('Detached while handling command'); + } + return { result: { value: 'ok' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const result = await mod.evaluate(1, '1'); + + expect(result).toBe('ok'); + expect(debuggerApi.attach).toHaveBeenCalledTimes(2); + }); + // Dead test: chrome.scripting.executeScript was removed from cdp.ts; // this test references functionality that no longer exists. Delete or rewrite // when cdp attach-recovery logic is next updated. diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 36c94ecc3..021cb9188 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -35,6 +35,45 @@ function isDebuggableUrl(url?: string): boolean { return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); } +function isRetryableDebuggerErrorMessage(message: string): boolean { + return message.includes('Inspected target navigated') + || message.includes('Target closed') + || message.includes('attach failed') + || message.includes('Debugger is not attached') + || message.includes('Detached while handling command') + || message.includes('chrome-extension://'); +} + +function retryDelayMsForDebuggerError(message: string): number { + return message.includes('Inspected target navigated') || message.includes('Target closed') + ? 200 + : 500; +} + +async function sendCommandWithRetry( + tabId: number, + method: string, + params: Record = {}, + aggressiveRetry: boolean = false, +): Promise { + const maxRetries = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await ensureAttached(tabId, aggressiveRetry); + return await chrome.debugger.sendCommand({ tabId }, method, params) as T; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (isRetryableDebuggerErrorMessage(msg) && attempt < maxRetries) { + attached.delete(tabId); + await new Promise(resolve => setTimeout(resolve, retryDelayMsForDebuggerError(msg))); + continue; + } + throw e; + } + } + throw new Error(`CDP command ${method} failed after retries`); +} + export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise { // Verify the tab URL is debuggable before attempting attach try { @@ -127,47 +166,23 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { - // Retry the entire evaluate (attach + command). - // Normal: 2 retries. Browser: 3 retries (tolerates extension interference). - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - - const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true, - }) as { - result?: { type: string; value?: unknown; description?: string; subtype?: string }; - exceptionDetails?: { exception?: { description?: string }; text?: string }; - }; - - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description - || result.exceptionDetails.text - || 'Eval error'; - throw new Error(errMsg); - } - - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - // Only retry on attach/debugger errors, not on JS eval errors - const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed'); - const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached') - || msg.includes('chrome-extension://'); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); // Force re-attach on next attempt - // SPA navigations recover quickly; debugger detach needs longer - const retryMs = isNavigateError ? 200 : 500; - await new Promise(resolve => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } + const result = await sendCommandWithRetry<{ + result?: { type: string; value?: unknown; description?: string; subtype?: string }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }>(tabId, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }, aggressiveRetry); + + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || 'Eval error'; + throw new Error(errMsg); } - throw new Error('evaluate: max retries exhausted'); + + return result.result?.value; } export const evaluateAsync = evaluate; @@ -180,21 +195,19 @@ export async function screenshot( tabId: number, options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {}, ): Promise { - await ensureAttached(tabId); - const format = options.format ?? 'png'; // For full-page screenshots, get the full page dimensions first if (options.fullPage) { // Get full page metrics - const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as { + const metrics = await sendCommandWithRetry<{ contentSize?: { width: number; height: number }; cssContentSize?: { width: number; height: number }; - }; + }>(tabId, 'Page.getLayoutMetrics'); const size = metrics.cssContentSize || metrics.contentSize; if (size) { // Set device metrics to full page size - await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { + await sendCommandWithRetry(tabId, 'Emulation.setDeviceMetricsOverride', { mobile: false, width: Math.ceil(size.width), height: Math.ceil(size.height), @@ -209,15 +222,15 @@ export async function screenshot( params.quality = Math.max(0, Math.min(100, options.quality)); } - const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as { + const result = await sendCommandWithRetry<{ data: string; // base64-encoded - }; + }>(tabId, 'Page.captureScreenshot', params); return result.data; } finally { // Reset device metrics if we changed them for full-page if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {}); + await sendCommandWithRetry(tabId, 'Emulation.clearDeviceMetricsOverride').catch(() => {}); } } } @@ -236,29 +249,27 @@ export async function setFileInputFiles( files: string[], selector?: string, ): Promise { - await ensureAttached(tabId); - // Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles) - await chrome.debugger.sendCommand({ tabId }, 'DOM.enable'); + await sendCommandWithRetry(tabId, 'DOM.enable'); // Get the document root - const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument') as { + const doc = await sendCommandWithRetry<{ root: { nodeId: number }; - }; + }>(tabId, 'DOM.getDocument'); // Find the file input element const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', { + const result = await sendCommandWithRetry<{ nodeId: number }>(tabId, 'DOM.querySelector', { nodeId: doc.root.nodeId, selector: query, - }) as { nodeId: number }; + }); if (!result.nodeId) { throw new Error(`No element found matching selector: ${query}`); } // Set files directly via CDP — Chrome reads from local filesystem - await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', { + await sendCommandWithRetry(tabId, 'DOM.setFileInputFiles', { files, nodeId: result.nodeId, }); @@ -268,8 +279,7 @@ export async function insertText( tabId: number, text: string, ): Promise { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text }); + await sendCommandWithRetry(tabId, 'Input.insertText', { text }); } function normalizeCapturePatterns(pattern?: string): string[] { @@ -323,8 +333,7 @@ export async function startNetworkCapture( tabId: number, pattern?: string, ): Promise { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + await sendCommandWithRetry(tabId, 'Network.enable'); networkCaptures.set(tabId, { patterns: normalizeCapturePatterns(pattern), entries: [], @@ -374,16 +383,26 @@ export function registerListeners(): void { if (!tabId) return; const state = networkCaptures.get(tabId); if (!state) return; - - if (method === 'Network.requestWillBeSent') { - const requestId = String(params?.requestId || ''); - const request = params?.request as { + const eventParams = (params ?? {}) as { + requestId?: string; + request?: { url?: string; method?: string; headers?: Record; postData?: string; hasPostData?: boolean; - } | undefined; + }; + response?: { + url?: string; + mimeType?: string; + status?: number; + headers?: Record; + }; + }; + + if (method === 'Network.requestWillBeSent') { + const requestId = String(eventParams.requestId || ''); + const request = eventParams.request; const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: request?.url, method: request?.method, @@ -405,13 +424,8 @@ export function registerListeners(): void { } if (method === 'Network.responseReceived') { - const requestId = String(params?.requestId || ''); - const response = params?.response as { - url?: string; - mimeType?: string; - status?: number; - headers?: Record; - } | undefined; + const requestId = String(eventParams.requestId || ''); + const response = eventParams.response; const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url, }); @@ -423,7 +437,7 @@ export function registerListeners(): void { } if (method === 'Network.loadingFinished') { - const requestId = String(params?.requestId || ''); + const requestId = String(eventParams.requestId || ''); const stateEntryIndex = state.requestToIndex.get(requestId); if (stateEntryIndex === undefined) return; const entry = state.entries[stateEntryIndex]; diff --git a/src/browser/errors-detach.test.ts b/src/browser/errors-detach.test.ts new file mode 100644 index 000000000..28d8bf72b --- /dev/null +++ b/src/browser/errors-detach.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { classifyBrowserError } from './errors.js'; + +describe('classifyBrowserError detach handling', () => { + it('treats detached-in-command failures as transient extension errors', () => { + expect(classifyBrowserError(new Error('Detached while handling command'))).toEqual({ + kind: 'extension-transient', + retryable: true, + delayMs: 1500, + }); + }); + + it('treats debugger-not-attached failures as transient extension errors', () => { + expect(classifyBrowserError(new Error('Debugger is not attached to the tab with id: 123.'))).toEqual({ + kind: 'extension-transient', + retryable: true, + delayMs: 1500, + }); + }); +}); diff --git a/src/browser/errors.ts b/src/browser/errors.ts index 1889b2f2e..d3f221989 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -41,6 +41,8 @@ const EXTENSION_TRANSIENT_PATTERNS = [ 'Extension disconnected', 'Extension not connected', 'attach failed', + 'Debugger is not attached', + 'Detached while handling command', 'no longer exists', 'CDP connection', 'Daemon command failed', diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 533d4b331..d4e5f79b8 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -69,6 +69,29 @@ describe('Page.evaluate', () => { expect(value).toBe(42); expect(sendCommandMock).toHaveBeenCalledTimes(2); }); + + it('drops stale page identity and retries when the daemon reports page-not-found', async () => { + sendCommandFullMock.mockResolvedValueOnce({ data: { title: 'ok' }, page: 'stale-page-id' }); + sendCommandMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Page not found: stale-page-id — stale page identity')) + .mockResolvedValueOnce(42); + + const page = new Page('site:notebooklm'); + await page.goto('https://notebooklm.google.com/'); + const value = await page.evaluate('21 + 21'); + + expect(value).toBe(42); + expect(sendCommandMock).toHaveBeenCalledTimes(3); + expect(sendCommandMock.mock.calls[1][1]).toEqual(expect.objectContaining({ + workspace: 'site:notebooklm', + page: 'stale-page-id', + })); + expect(sendCommandMock.mock.calls[2][1]).toEqual(expect.objectContaining({ + workspace: 'site:notebooklm', + })); + expect(sendCommandMock.mock.calls[2][1]).not.toHaveProperty('page'); + }); }); describe('Page network capture compatibility', () => { diff --git a/src/browser/page.ts b/src/browser/page.ts index c81f373d0..efcf40b5b 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -26,6 +26,13 @@ function isUnsupportedNetworkCaptureError(err: unknown): boolean { || (normalized.includes('network capture') && normalized.includes('not supported')); } +function isStalePageIdentityError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const normalized = message.toLowerCase(); + return normalized.includes('page not found:') + || normalized.includes('stale page identity'); +} + /** * Page — implements IPage by talking to the daemon via HTTP. */ @@ -42,6 +49,17 @@ export class Page extends BasePage { private _networkCaptureUnsupported = false; private _networkCaptureWarned = false; + private async _retryExecWithFreshPageIdentity(code: string): Promise { + const previousPage = this._page; + this._page = undefined; + try { + return await sendCommand('exec', { code, ...this._cmdOpts() }); + } catch (err) { + this._page = previousPage; + throw err; + } + } + /** Helper: spread workspace into command params */ private _wsOpt(): { workspace: string; idleTimeout?: number } { return { workspace: this.workspace, ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }) }; @@ -78,6 +96,10 @@ export class Page extends BasePage { try { await sendCommand('exec', combinedOpts); } catch (err) { + if (isStalePageIdentityError(err)) { + await this._retryExecWithFreshPageIdentity(combinedCode); + return; + } const advice = classifyBrowserError(err); // Only settle-retry on target navigation (SPA client-side redirects). // Extension/daemon errors are already retried by sendCommandRaw — @@ -97,7 +119,11 @@ export class Page extends BasePage { code: generateStealthJs(), ...this._cmdOpts(), }); - } catch { + } catch (err) { + if (isStalePageIdentityError(err)) { + await this._retryExecWithFreshPageIdentity(generateStealthJs()).catch(() => {}); + return; + } // Non-fatal: stealth is best-effort } } @@ -123,6 +149,9 @@ export class Page extends BasePage { try { return await sendCommand('exec', { code, ...this._cmdOpts() }); } catch (err) { + if (isStalePageIdentityError(err)) { + return this._retryExecWithFreshPageIdentity(code); + } const advice = classifyBrowserError(err); if (advice.kind !== 'target-navigation') throw err; await new Promise((resolve) => setTimeout(resolve, advice.delayMs)); diff --git a/src/runtime.test.ts b/src/runtime.test.ts new file mode 100644 index 000000000..8cfb0d15c --- /dev/null +++ b/src/runtime.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BrowserBridge, CDPBridge } from './browser/index.js'; +import { getBrowserFactory } from './runtime.js'; + +describe('getBrowserFactory', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('uses BrowserBridge for regular browser sites by default', () => { + expect(getBrowserFactory('douban')).toBe(BrowserBridge); + }); + + it('uses CDPBridge for browser sites when OPENCLI_CDP_ENDPOINT is set', () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222'); + + expect(getBrowserFactory('douban')).toBe(CDPBridge); + }); +}); diff --git a/src/runtime.ts b/src/runtime.ts index 0ea88a6a5..a538cc40c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -6,9 +6,11 @@ import { log } from './logger.js'; /** * Returns the appropriate browser factory based on site type. - * Uses CDPBridge for registered Electron apps, otherwise BrowserBridge. + * Uses CDPBridge for registered Electron apps or when a manual CDP endpoint is + * provided, otherwise BrowserBridge. */ export function getBrowserFactory(site?: string): new () => IBrowserFactory { + if (process.env.OPENCLI_CDP_ENDPOINT) return CDPBridge; if (site && isElectronApp(site)) return CDPBridge; return BrowserBridge; }