From b87bc0c6a0bb6ba2847fe06068c422b84d7c8beb Mon Sep 17 00:00:00 2001 From: warkcod Date: Sat, 11 Apr 2026 11:42:53 +0800 Subject: [PATCH 1/9] feat(scys): migrate adapters onto latest upstream --- clis/scys/activity.js | 20 + clis/scys/article.js | 22 + clis/scys/common.js | 99 ++ clis/scys/common.test.js | 68 ++ clis/scys/course-download.js | 104 ++ clis/scys/course-download.test.js | 81 ++ clis/scys/course-utils.js | 137 +++ clis/scys/course-utils.test.js | 93 ++ clis/scys/course.js | 50 + clis/scys/extractors.js | 1227 ++++++++++++++++++++++ clis/scys/extractors.toc.test.js | 75 ++ clis/scys/feed.js | 23 + clis/scys/opportunity-utils.js | 88 ++ clis/scys/opportunity-utils.test.js | 60 ++ clis/scys/opportunity.js | 67 ++ clis/scys/read.js | 57 + clis/scys/toc.js | 20 + docs/adapters/browser/scys.md | 55 + docs/adapters/index.md | 1 + docs/developer/scys-schema-guidelines.md | 59 ++ 20 files changed, 2406 insertions(+) create mode 100644 clis/scys/activity.js create mode 100644 clis/scys/article.js create mode 100644 clis/scys/common.js create mode 100644 clis/scys/common.test.js create mode 100644 clis/scys/course-download.js create mode 100644 clis/scys/course-download.test.js create mode 100644 clis/scys/course-utils.js create mode 100644 clis/scys/course-utils.test.js create mode 100644 clis/scys/course.js create mode 100644 clis/scys/extractors.js create mode 100644 clis/scys/extractors.toc.test.js create mode 100644 clis/scys/feed.js create mode 100644 clis/scys/opportunity-utils.js create mode 100644 clis/scys/opportunity-utils.test.js create mode 100644 clis/scys/opportunity.js create mode 100644 clis/scys/read.js create mode 100644 clis/scys/toc.js create mode 100644 docs/adapters/browser/scys.md create mode 100644 docs/developer/scys-schema-guidelines.md 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..f81867515 --- /dev/null +++ b/clis/scys/article.js @@ -0,0 +1,22 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractScysArticle } from './extractors.js'; +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) => { + return extractScysArticle(page, String(kwargs.url), { + waitSeconds: Number(kwargs.wait ?? 5), + maxLength: Number(kwargs['max-length'] ?? 4000), + }); + }, +}); 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..67c7f5abb --- /dev/null +++ b/clis/scys/extractors.js @@ -0,0 +1,1227 @@ +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'], +]; +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 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'); + } +} +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 url = toScysArticleUrl(inputUrl); + 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 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, + }; + })() + `); + 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 (!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)); + await gotoAndWait(page, url, waitSeconds); + await ensureScysLogin(page); + await ensureScysFeedReady(page); + // API-first extraction: + // feed pages use /shengcai-web/client/homePage/searchTopic as list source. + 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 latest = intercepted + .filter((entry) => { + const data = entry?.data; + return data && Array.isArray(data.items) && data.items.some((item) => item?.topicDTO); + }) + .at(-1); + let normalized = []; + if (latest?.data?.items?.length) { + normalized = latest.data.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 url = pickPreferredScysLink([ + item?.detailUrl, + buildScysTopicLink(entityType, topicId), + 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, + url, + raw_url: url, + images, + image_count: images.length, + }; + }).filter((row) => row.title || row.summary); + } + // 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 url = pickPreferredScysLink(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, + url, + raw_url: url, + images: [], + image_count: 0, + }; + }).filter((row) => row.title || row.summary); + } + 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); + // API-first extraction. The page internally requests: + // /shengcai-web/client/homePage/searchTopic + // We intercept this payload to get stable fields (time, tags, images, topic ids). + 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 latest = intercepted + .filter((entry) => { + const data = entry?.data; + return data && Array.isArray(data.items) && data.items.length > 0; + }) + .at(-1); + let normalized = []; + if (latest?.data?.items?.length) { + normalized = latest.data.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); + const topicId = cleanText(topic.topicId || topic.entityId); + const images = Array.isArray(topic.imageList) + ? topic.imageList.map((u) => cleanText(u)).filter(Boolean) + : []; + const url = cleanText(item.detailUrl) || buildScysTopicLink(entityType, topicId); + 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, + raw_url: url, + topic_id: topicId, + entity_type: entityType, + images, + image_count: images.length, + }; + }); + } + // 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 topicId = inferTopicIdFromImageUrls(images); + 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 url = cleanText(row.link ?? '') || buildScysTopicLink('xq_topic', topicId); + 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, + raw_url: url, + topic_id: topicId, + entity_type: topicId ? 'xq_topic' : '', + images, + image_count: images.length, + }; + }); + } + 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.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. From c228c4747c8e95e2e26e157ac70571ee53fd29a4 Mon Sep 17 00:00:00 2001 From: warkcod Date: Thu, 16 Apr 2026 23:31:21 +0800 Subject: [PATCH 2/9] fix(scys): stabilize list identity and detail hydration SCYS list extraction could return only third-party links even when the page already exposed a stable topic id, and detail reads could succeed against the shell frame before the post hydrated. This change prefers SCYS articleDetail identity, backfills missing topic metadata from hydrated page/cache state, and retries detail extraction past placeholder shells. Constraint: SCYS exposes post identity across intercepted payloads, DOM cards, and client-side state with inconsistent hydration timing Rejected: Increase fixed waits for every extractor | still nondeterministic and needlessly slows healthy runs Confidence: medium Scope-risk: moderate Directive: Keep SCYS articleDetail URLs as the canonical identity whenever a topic id exists; external links belong in source_links/external_links Tested: npx vitest run --project adapter clis/scys/*.test.js Tested: npm run typecheck Tested: npx tsx src/main.ts scys article 'https://scys.com/articleDetail/xq_topic/55522122288425554' --wait 6 -f json Tested: npx tsx src/main.ts scys feed 'https://scys.com/?filter=essence' --wait 6 --limit 3 -f json Tested: npx tsx src/main.ts scys opportunity 'https://scys.com/opportunity' --wait 6 --limit 3 -f json Not-tested: Rebuilding and reinstalling the full local package under /Users/mac/.opencli-scys --- clis/scys/extractors.js | 403 ++++++++++++++++++++++++++++++----- clis/scys/extractors.test.js | 228 ++++++++++++++++++++ 2 files changed, 582 insertions(+), 49 deletions(-) create mode 100644 clis/scys/extractors.test.js diff --git a/clis/scys/extractors.js b/clis/scys/extractors.js index 67c7f5abb..03e458f48 100644 --- a/clis/scys/extractors.js +++ b/clis/scys/extractors.js @@ -11,6 +11,8 @@ const SCYS_TEXT_FIXUPS = [ [/\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); @@ -30,6 +32,157 @@ function pickPreferredScysLink(candidates) { 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, + }; + }); +} function parseCnNumberToken(token) { const raw = cleanText(token); if (!raw) @@ -586,41 +739,99 @@ export async function ensureScysLogin(page) { throw new AuthRequiredError(SCYS_DOMAIN, 'SCYS content requires a logged-in browser session'); } } -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; +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 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(window.localStorage), + ...collectStorageRoots(window.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 : []; } -export async function extractScysArticle(page, inputUrl, opts = {}) { - const url = toScysArticleUrl(inputUrl); - 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 page.evaluate(` +async function readScysArticlePayload(page) { + return page.evaluate(` (() => { const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const normalizeUrl = (value) => clean(value).replace(/\\s+/g, ''); @@ -740,6 +951,75 @@ export async function extractScysArticle(page, inputUrl, opts = {}) { }; })() `); +} +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; +} +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 url = toScysArticleUrl(inputUrl); + 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 waitForScysArticlePayload(page, 3); if (!payload) { throw new EmptyResultError('scys/article', 'Failed to extract article detail page'); } @@ -768,6 +1048,9 @@ export async function extractScysArticle(page, inputUrl, opts = {}) { 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'); } @@ -866,9 +1149,8 @@ export async function extractScysFeed(page, inputUrl, opts = {}) { 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 url = pickPreferredScysLink([ + const links = buildScysListLinkFields(item?.detailUrl, entityType, topicId, [ item?.detailUrl, - buildScysTopicLink(entityType, topicId), topic?.externalLink, ]); const images = Array.isArray(topic.imageList) @@ -887,8 +1169,12 @@ export async function extractScysFeed(page, inputUrl, opts = {}) { tags, interactions, interactions_display: interactions.display, - url, - raw_url: url, + 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, }; @@ -951,7 +1237,9 @@ export async function extractScysFeed(page, inputUrl, opts = {}) { 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 url = pickPreferredScysLink(row.links ?? []); + 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), @@ -962,13 +1250,18 @@ export async function extractScysFeed(page, inputUrl, opts = {}) { tags, interactions, interactions_display: interactions.display, - url, - raw_url: url, + 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'); } @@ -1035,7 +1328,10 @@ export async function extractScysOpportunity(page, inputUrl, opts = {}) { const images = Array.isArray(topic.imageList) ? topic.imageList.map((u) => cleanText(u)).filter(Boolean) : []; - const url = cleanText(item.detailUrl) || buildScysTopicLink(entityType, topicId); + 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)); @@ -1050,10 +1346,12 @@ export async function extractScysOpportunity(page, inputUrl, opts = {}) { tags: normalizedTags, interactions, interactions_display: interactions.display, - url, - raw_url: url, + 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, }; @@ -1096,11 +1394,15 @@ export async function extractScysOpportunity(page, inputUrl, opts = {}) { `); normalized = (rows ?? []).slice(0, limit).map((row, index) => { const images = (row.image_urls ?? []).map((u) => cleanText(u)).filter(Boolean); - const topicId = inferTopicIdFromImageUrls(images); + 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 url = cleanText(row.link ?? '') || buildScysTopicLink('xq_topic', topicId); + const links = buildScysListLinkFields(preferredUrl, entityType, topicId, [row.link ?? '']); const normalizedFlags = (row.flags ?? []).map((f) => polishScysText(f)).filter(Boolean); return { rank: index + 1, @@ -1113,15 +1415,18 @@ export async function extractScysOpportunity(page, inputUrl, opts = {}) { tags: tags.map((tag) => polishScysText(tag)).filter(Boolean), interactions, interactions_display: interactions.display, - url, - raw_url: url, + url: links.url, + raw_url: links.raw_url, topic_id: topicId, - entity_type: topicId ? 'xq_topic' : '', + 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'); } diff --git a/clis/scys/extractors.test.js b/clis/scys/extractors.test.js new file mode 100644 index 000000000..bec32f6de --- /dev/null +++ b/clis/scys/extractors.test.js @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { extractScysArticle, extractScysFeed, extractScysOpportunity } from './extractors.js'; + +function createScysPageMock({ + loginState, + evaluateResults = [], + interceptedRequests = [], +} = {}) { + const queue = [...evaluateResults]; + 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, + loginCtaText: false, + }; + } + 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('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('recovers topic identity from page cache when DOM fallback only sees an external link', async () => { + const page = createScysPageMock({ + 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'], + }, + ], + ], + interceptedRequests: [], + }); + + 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'], + }); + }); +}); From 6c981cd527867d629836f41c11692032660ab828 Mon Sep 17 00:00:00 2001 From: warkcod Date: Thu, 16 Apr 2026 23:34:13 +0800 Subject: [PATCH 3/9] fix(scys): guard cache backfill on storage-restricted pages The page-cache backfill walks browser state to recover stable SCYS topic identity, but some extension/page contexts deny direct storage access and were failing before extraction could continue. This change treats storage access as optional and keeps the fallback path alive when the page runs in a restricted context. Constraint: Browser command contexts can evaluate inside pages where localStorage/sessionStorage are unavailable or throw SecurityError Rejected: Remove page-cache backfill entirely | would reintroduce the missing topic_id/articleDetail regression on SCYS lists Confidence: high Scope-risk: narrow Directive: Any browser-state probe used during fallback extraction must tolerate storage access failures and continue with the remaining signals Tested: npx vitest run --project adapter clis/scys/*.test.js Tested: npm run typecheck Not-tested: Parallel browser-command contention against the same daemon session --- clis/scys/extractors.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/clis/scys/extractors.js b/clis/scys/extractors.js index 03e458f48..456843374 100644 --- a/clis/scys/extractors.js +++ b/clis/scys/extractors.js @@ -754,6 +754,13 @@ async function captureScysPageCache(page) { 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; @@ -777,8 +784,8 @@ async function captureScysPageCache(page) { 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(window.localStorage), - ...collectStorageRoots(window.sessionStorage), + ...collectStorageRoots(getStorage('localStorage')), + ...collectStorageRoots(getStorage('sessionStorage')), ].filter(Boolean); const seen = new WeakSet(); const out = []; From fa24ea864c58e9fc61bc2c5504db8ab6d91b32c9 Mon Sep 17 00:00:00 2001 From: warkcod Date: Fri, 17 Apr 2026 00:13:28 +0800 Subject: [PATCH 4/9] fix(scys): fetch essence and opportunity lists with explicit filters The previous list extraction still depended on tab toggles plus response capture. On current SCYS pages that path is nondeterministic: switching away from the active tab can trigger a request for the wrong filter, while switching back may not issue a request at all. This change makes essence and opportunity fetch the searchTopic payload directly with explicit authenticated parameters, keeping the old capture path only as a fallback when direct auth is unavailable. Constraint: Current SCYS frontend no longer exposes the expected list payload through reliable UI-triggered request timing on cold start Rejected: Keep relying on interceptor + tab toggles | can return EMPTY_RESULT or silently capture the wrong column Confidence: high Scope-risk: moderate Directive: For SCYS list extraction, prefer explicit authenticated API params over UI-state inference whenever the route maps cleanly to searchTopic Tested: npx vitest run --project adapter clis/scys/*.test.js Tested: npm run typecheck Tested: npx tsx src/main.ts scys article 'https://scys.com/articleDetail/xq_topic/55522122288425554' --wait 6 -f json Tested: npx tsx src/main.ts scys feed 'https://scys.com/?filter=essence' --wait 6 --limit 3 -f json Tested: npx tsx src/main.ts scys opportunity 'https://scys.com/opportunity' --wait 6 --limit 3 -f json Tested: /Users/mac/.opencli-scys/bin/opencli scys article 'https://scys.com/articleDetail/xq_topic/55522122288425554' --wait 6 -f json Tested: /Users/mac/.opencli-scys/bin/opencli scys feed 'https://scys.com/?filter=essence' --wait 6 --limit 3 -f json Tested: /Users/mac/.opencli-scys/bin/opencli scys opportunity 'https://scys.com/opportunity' --wait 6 --limit 3 -f json Not-tested: Personal feed via an explicit API path (still uses the existing fallback extractor path) --- clis/scys/extractors.js | 296 ++++++++++++++++++++++------------- clis/scys/extractors.test.js | 123 ++++++++++++++- 2 files changed, 311 insertions(+), 108 deletions(-) diff --git a/clis/scys/extractors.js b/clis/scys/extractors.js index 456843374..1d411f9dc 100644 --- a/clis/scys/extractors.js +++ b/clis/scys/extractors.js @@ -183,6 +183,147 @@ function enrichScysListRows(rows, cacheEntries) { }; }); } +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) @@ -1086,13 +1227,27 @@ export async function extractScysFeed(page, inputUrl, opts = {}) { 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); - // API-first extraction: - // feed pages use /shengcai-web/client/homePage/searchTopic as list source. - await page.installInterceptor('shengcai-web/client'); - await page.evaluate(` + 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)); @@ -1138,54 +1293,14 @@ export async function extractScysFeed(page, inputUrl, opts = {}) { await sleep(300); })() `); - const intercepted = await page.getInterceptedRequests(); - const latest = intercepted - .filter((entry) => { - const data = entry?.data; - return data && Array.isArray(data.items) && data.items.some((item) => item?.topicDTO); - }) - .at(-1); - let normalized = []; - if (latest?.data?.items?.length) { - normalized = latest.data.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); + 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) { @@ -1281,11 +1396,20 @@ export async function extractScysOpportunity(page, inputUrl, opts = {}) { const tab = normalizeOpportunityTab(opts.tab); await gotoAndWait(page, url, waitSeconds); await ensureScysLogin(page); - // API-first extraction. The page internally requests: - // /shengcai-web/client/homePage/searchTopic - // We intercept this payload to get stable fields (time, tags, images, topic ids). - await page.installInterceptor('shengcai-web/client/homePage/searchTopic'); - await page.evaluate(` + 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)); @@ -1313,56 +1437,14 @@ export async function extractScysOpportunity(page, inputUrl, opts = {}) { await sleep(800); })() `); - const intercepted = await page.getInterceptedRequests(); - const latest = intercepted - .filter((entry) => { - const data = entry?.data; - return data && Array.isArray(data.items) && data.items.length > 0; - }) - .at(-1); - let normalized = []; - if (latest?.data?.items?.length) { - normalized = latest.data.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); - 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, - }; - }); + 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) { diff --git a/clis/scys/extractors.test.js b/clis/scys/extractors.test.js index bec32f6de..936f08858 100644 --- a/clis/scys/extractors.test.js +++ b/clis/scys/extractors.test.js @@ -5,6 +5,7 @@ function createScysPageMock({ loginState, evaluateResults = [], interceptedRequests = [], + evaluateMock, } = {}) { const queue = [...evaluateResults]; return { @@ -21,6 +22,9 @@ function createScysPageMock({ loginCtaText: false, }; } + if (typeof evaluateMock === 'function') { + return evaluateMock(js, queue); + } return queue.shift(); }, autoScroll: async () => {}, @@ -47,6 +51,59 @@ function createScysPageMock({ } 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], @@ -98,8 +155,73 @@ describe('extractScysFeed', () => { }); 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, [ @@ -128,7 +250,6 @@ describe('extractScysOpportunity', () => { }, ], ], - interceptedRequests: [], }); const rows = await extractScysOpportunity(page, 'https://scys.com/opportunity', { From 4d94bf2738ea9171b9a5276b5ec9f9a21405cad8 Mon Sep 17 00:00:00 2001 From: warkcod Date: Fri, 17 Apr 2026 13:56:34 +0800 Subject: [PATCH 5/9] fix(browser): retry detached debugger commands SCYS regressions still showed command failures even after the list extractors were stabilized. The remaining failures came from the browser control plane: daemon commands treated "Detached while handling command" as terminal, and CDP retries only covered a narrower set of attach errors. This change promotes detached and not-attached debugger errors into the transient-retry path and reuses the retry logic across CDP commands instead of only Runtime.evaluate. Constraint: The currently connected Browser Bridge can report healthy status while individual tab debugger sessions detach mid-command Rejected: Rely on doctor/connection health alone | does not protect command execution when a specific tab loses debugger attachment Confidence: medium Scope-risk: moderate Directive: Any new CDP command path should go through the shared retry helper so debugger-detach handling stays consistent Tested: npx vitest run --project extension extension/src/cdp.test.ts Tested: npx vitest run --project unit src/browser/errors-detach.test.ts Tested: npx vitest run --project adapter clis/scys/*.test.js Tested: npm run typecheck Tested: npm --prefix extension run typecheck Tested: npm --prefix extension run build Not-tested: Reloading the currently connected external Browser Bridge v1.5.1 in Chrome to exercise the new extension dist live --- extension/dist/background.js | 105 +++++++++++--------- extension/package-lock.json | 141 +++++++++++++------------- extension/src/background.ts | 6 +- extension/src/cdp.test.ts | 23 +++++ extension/src/cdp.ts | 160 ++++++++++++++++-------------- src/browser/errors-detach.test.ts | 20 ++++ src/browser/errors.ts | 2 + 7 files changed, 263 insertions(+), 194 deletions(-) create mode 100644 src/browser/errors-detach.test.ts diff --git a/extension/dist/background.js b/extension/dist/background.js index 7b30a6d53..5e27b2c88 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) { diff --git a/extension/package-lock.json b/extension/package-lock.json index 2288e01cf..ad9e89d47 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,19 +1,19 @@ { "name": "opencli-extension", - "version": "1.5.5", + "version": "file:../../../private/tmp/opencli-sync-feat-scys/extension", "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", "vite": "^6.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", @@ -30,7 +30,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/android-arm": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", @@ -47,7 +47,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/android-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", @@ -64,7 +64,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/android-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", @@ -81,7 +81,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/darwin-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", @@ -98,7 +98,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/darwin-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", @@ -115,7 +115,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/freebsd-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/freebsd-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", @@ -132,7 +132,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/freebsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", @@ -149,7 +149,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-arm": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-arm": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", @@ -166,7 +166,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", @@ -183,7 +183,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-ia32": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", @@ -200,7 +200,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-loong64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-loong64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", @@ -217,7 +217,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-mips64el": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-mips64el": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", @@ -234,7 +234,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-ppc64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", @@ -251,7 +251,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-riscv64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-riscv64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", @@ -268,7 +268,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-s390x": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-s390x": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", @@ -285,7 +285,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/linux-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", @@ -302,7 +302,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/netbsd-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", @@ -319,7 +319,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/netbsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", @@ -336,7 +336,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/openbsd-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", @@ -353,7 +353,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/openbsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", @@ -370,7 +370,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/openharmony-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/openharmony-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", @@ -387,7 +387,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/sunos-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", @@ -404,7 +404,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/win32-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", @@ -421,7 +421,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/win32-ia32": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", @@ -438,7 +438,7 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/win32-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", @@ -455,7 +455,7 @@ "node": ">=18" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -469,7 +469,7 @@ "android" ] }, - "node_modules/@rollup/rollup-android-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -483,7 +483,7 @@ "android" ] }, - "node_modules/@rollup/rollup-darwin-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -497,7 +497,7 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -511,7 +511,7 @@ "darwin" ] }, - "node_modules/@rollup/rollup-freebsd-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -525,7 +525,7 @@ "freebsd" ] }, - "node_modules/@rollup/rollup-freebsd-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -539,7 +539,7 @@ "freebsd" ] }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -553,7 +553,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -567,7 +567,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -581,7 +581,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-arm64-musl": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -595,7 +595,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -609,7 +609,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loong64-musl": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -623,7 +623,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -637,7 +637,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -651,7 +651,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -665,7 +665,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -679,7 +679,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -693,7 +693,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -707,7 +707,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -721,7 +721,7 @@ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -735,7 +735,7 @@ "openbsd" ] }, - "node_modules/@rollup/rollup-openharmony-arm64": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -749,7 +749,7 @@ "openharmony" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -763,7 +763,7 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -777,7 +777,7 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-gnu": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -791,7 +791,7 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -805,7 +805,7 @@ "win32" ] }, - "node_modules/@types/chrome": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/chrome": { "version": "0.0.287", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", @@ -816,14 +816,14 @@ "@types/har-format": "*" } }, - "node_modules/@types/estree": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, - "node_modules/@types/filesystem": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/filesystem": { "version": "0.0.36", "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", @@ -833,21 +833,21 @@ "@types/filewriter": "*" } }, - "node_modules/@types/filewriter": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/filewriter": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", "dev": true, "license": "MIT" }, - "node_modules/@types/har-format": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/har-format": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", "dev": true, "license": "MIT" }, - "node_modules/esbuild": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", @@ -889,7 +889,7 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/fdir": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", @@ -907,7 +907,7 @@ } } }, - "node_modules/fsevents": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", @@ -922,7 +922,7 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/nanoid": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", @@ -941,20 +941,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/picocolors": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, - "node_modules/picomatch": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -962,7 +961,7 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/postcss": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", @@ -991,7 +990,7 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/rollup": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", @@ -1036,7 +1035,7 @@ "fsevents": "~2.3.2" } }, - "node_modules/source-map-js": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", @@ -1046,7 +1045,7 @@ "node": ">=0.10.0" } }, - "node_modules/tinyglobby": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", @@ -1063,7 +1062,7 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/typescript": { + "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", @@ -1077,7 +1076,7 @@ "node": ">=14.17" } }, - "node_modules/vite": { + "../../../private/tmp/opencli-sync-feat-scys/extension/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==", diff --git a/extension/src/background.ts b/extension/src/background.ts index d4fe12d77..02360c6e6 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); 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', From 0c37f37f40944e2f922ed01c9b15f39f6fe7bd94 Mon Sep 17 00:00:00 2001 From: warkcod Date: Fri, 17 Apr 2026 16:20:14 +0800 Subject: [PATCH 6/9] fix(extension): initialize service worker on module load The unpacked Browser Bridge can be brought up in contexts where the MV3 service worker is evaluated without a fresh install/startup event. In that state the bridge may never call connect(), leaving doctor green only after an older extension reconnects. This change initializes eagerly on module load while keeping the existing one-time guard. Constraint: MV3 service workers are not guaranteed to re-enter through runtime.onInstalled or runtime.onStartup after every reload/profile restart Rejected: Depend only on startup/install listeners | leaves newly loaded unpacked extensions idle until some later event happens Confidence: medium Scope-risk: narrow Directive: Background bootstrap for the Browser Bridge should not depend on lifecycle events alone; keep module-load init idempotent Tested: npx vitest run --project extension extension/src/background.test.ts extension/src/cdp.test.ts Tested: npm --prefix extension run typecheck Tested: npm --prefix extension run build Tested: python3 /Users/mac/clawd/scys-report/scys_report.py build-bundle --date 2026-04-17 --out-dir /Users/mac/clawd/scys-report/runs/2026-04-17-verify-rerun --essence-limit 3 --opportunity-limit 3 --personal-limit 3 --strict Tested: python3 /Users/mac/clawd/scys-report/scys_report.py build-bundle --date 2026-04-17 --out-dir /Users/mac/clawd/scys-report/runs/2026-04-17-verify-rerun-2 --essence-limit 3 --opportunity-limit 3 --personal-limit 3 --strict Not-tested: Making doctor report the newly synced unpacked extension version instead of the stale cached version string --- extension/dist/background.js | 1 + extension/src/background.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/extension/dist/background.js b/extension/dist/background.js index 5e27b2c88..4fbd472cf 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1060,3 +1060,4 @@ async function handleBindCurrent(cmd, workspace) { workspace }); } +initialize(); diff --git a/extension/src/background.ts b/extension/src/background.ts index 02360c6e6..6fed05683 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -937,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(); From 2f111e702bdce1b57d2dff28fe6fc4c07f57b9fb Mon Sep 17 00:00:00 2001 From: warkcod Date: Sat, 18 Apr 2026 22:17:08 +0800 Subject: [PATCH 7/9] Reduce flaky SCYS article fetch failures during repeated runs The detail extractor was still failing on the same SCYS article URL even after list extraction became deterministic. Two recoverable failure modes remained: stale page identities after browser navigation and shell-only article loads that became healthy on a subsequent full rerun. This change clears stale page identity before retrying execs and gives `scys article` a bounded full-command retry path on shell-only EMPTY_RESULT outcomes. Constraint: SCYS article detail pages can hydrate after the shell is visible, and browser target identities can drift across repeated one-shot commands Rejected: Increase a single fixed wait only | still left repeated empty shell states and stale-page failures in real runs Confidence: medium Scope-risk: narrow Directive: Keep SCYS article retries bounded and targeted to retryable shell/identity errors; avoid broad retries for unrelated extraction failures Tested: npx vitest run --project unit src/browser/page.test.ts src/browser/errors-detach.test.ts Tested: npx vitest run --project adapter clis/scys/*.test.js Tested: npm run typecheck Tested: /Users/mac/.opencli-scys/bin/opencli scys article https://scys.com/articleDetail/xq_topic/14422288551185512 -f json (12/12 success after final retry tuning) Not-tested: Full bundle strict run on 2026-04-18 dataset after the final article-only retry increase --- clis/scys/article.js | 30 ++++++++++- clis/scys/article.test.js | 71 ++++++++++++++++++++++++++ clis/scys/extractors.js | 28 ++++++++++- clis/scys/extractors.test.js | 98 ++++++++++++++++++++++++++++++++++-- src/browser/page.test.ts | 23 +++++++++ src/browser/page.ts | 31 +++++++++++- 6 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 clis/scys/article.test.js diff --git a/clis/scys/article.js b/clis/scys/article.js index f81867515..f2a599bc1 100644 --- a/clis/scys/article.js +++ b/clis/scys/article.js @@ -1,5 +1,14 @@ +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', @@ -14,9 +23,26 @@ cli({ ], columns: ['topic_id', 'entity_type', 'title', 'author', 'time', 'tags', 'flags', 'image_count', 'external_link_count', 'content', 'ai_summary', 'url'], func: async (page, kwargs) => { - return extractScysArticle(page, String(kwargs.url), { + 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/extractors.js b/clis/scys/extractors.js index 1d411f9dc..ea176fa96 100644 --- a/clis/scys/extractors.js +++ b/clis/scys/extractors.js @@ -1133,6 +1133,29 @@ async function waitForScysArticlePayload(page, attempts = 3) { } 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); } @@ -1161,13 +1184,14 @@ export async function extractScysToc(page, courseInput, opts = {}) { return normalized; } export async function extractScysArticle(page, inputUrl, opts = {}) { - const url = toScysArticleUrl(inputUrl); + 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 waitForScysArticlePayload(page, 3); + const payload = await resolveScysArticlePayload(page, url, waitSeconds); if (!payload) { throw new EmptyResultError('scys/article', 'Failed to extract article detail page'); } diff --git a/clis/scys/extractors.test.js b/clis/scys/extractors.test.js index 936f08858..3dd354055 100644 --- a/clis/scys/extractors.test.js +++ b/clis/scys/extractors.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { extractScysArticle, extractScysFeed, extractScysOpportunity } from './extractors.js'; function createScysPageMock({ @@ -9,7 +9,7 @@ function createScysPageMock({ } = {}) { const queue = [...evaluateResults]; return { - goto: async () => {}, + goto: vi.fn(async () => {}), wait: async () => {}, evaluate: async (js) => { if (js.includes('const text = (document.body?.innerText ||') && js.includes('hasContentSignals')) { @@ -272,7 +272,7 @@ describe('extractScysOpportunity', () => { }); describe('extractScysArticle', () => { - it('waits past shell placeholders and returns hydrated article content', async () => { + it('waits past shell placeholders and returns hydrated article content', async () => { const page = createScysPageMock({ evaluateResults: [ { @@ -346,4 +346,96 @@ describe('extractScysArticle', () => { 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/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)); From badb8f6e19e597513f03a7396d95fdc6a6807a27 Mon Sep 17 00:00:00 2001 From: warkcod Date: Sun, 19 Apr 2026 11:39:57 +0800 Subject: [PATCH 8/9] Fix extension lockfile drift and keep SCYS article retries bounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining PR #957 CI failure was not in SCYS extraction logic anymore — it was the extension build workflow. `extension/package-lock.json` had drifted into an invalid state, so `npm ci` failed before the extension build even started. This refreshes the extension lockfile to a clean npm-ci-compatible state and keeps the background test typings aligned with the current listener mocks. Constraint: GitHub Actions build-extension job uses `npm ci` in extension/, so any lockfile drift hard-fails the PR before adapter checks matter Rejected: Ignore the build-extension failure and rely on local verification only | PR remains red and cannot be merged safely Confidence: high Scope-risk: narrow Directive: When extension dependencies change or a worktree leaks lockfile metadata, regenerate extension/package-lock.json from a clean extension/ install and re-check npm ci Tested: cd extension && npm ci Tested: cd extension && npm run typecheck Tested: cd extension && npm run build Tested: npx vitest run --project extension extension/src/background.test.ts extension/src/cdp.test.ts Tested: npx vitest run --project adapter clis/scys/*.test.js Tested: npx vitest run --project unit src/browser/page.test.ts src/browser/errors-detach.test.ts Tested: npm run typecheck Not-tested: Re-running the GitHub Actions build job remotely after push --- extension/package-lock.json | 485 +++++++++++++++++-------------- extension/src/background.test.ts | 7 +- 2 files changed, 267 insertions(+), 225 deletions(-) diff --git a/extension/package-lock.json b/extension/package-lock.json index ad9e89d47..94ede7fa8 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,6 +1,6 @@ { "name": "opencli-extension", - "version": "file:../../../private/tmp/opencli-sync-feat-scys/extension", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -13,9 +13,9 @@ "vite": "^6.0.0" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/aix-ppc64": { + "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" @@ -30,9 +30,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/android-arm": { + "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" @@ -47,9 +47,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/android-arm64": { + "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" @@ -64,9 +64,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/android-x64": { + "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" @@ -81,9 +81,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/darwin-arm64": { + "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" @@ -98,9 +98,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/darwin-x64": { + "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" @@ -115,9 +115,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/freebsd-arm64": { + "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" @@ -132,9 +132,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/freebsd-x64": { + "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" @@ -149,9 +149,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-arm": { + "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" @@ -166,9 +166,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-arm64": { + "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" @@ -183,9 +183,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-ia32": { + "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" @@ -200,9 +200,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-loong64": { + "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" @@ -217,9 +217,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-mips64el": { + "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" @@ -234,9 +234,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-ppc64": { + "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" @@ -251,9 +251,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-riscv64": { + "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" @@ -268,9 +268,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-s390x": { + "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" @@ -285,9 +285,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/linux-x64": { + "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" @@ -302,9 +302,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/netbsd-arm64": { + "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" @@ -319,9 +319,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/netbsd-x64": { + "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" @@ -336,9 +336,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/openbsd-arm64": { + "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" @@ -353,9 +353,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/openbsd-x64": { + "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" @@ -370,9 +370,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/openharmony-arm64": { + "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" @@ -387,9 +387,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/sunos-x64": { + "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" @@ -404,9 +404,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/win32-arm64": { + "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" @@ -421,9 +421,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/win32-ia32": { + "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" @@ -438,9 +438,9 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@esbuild/win32-x64": { + "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" @@ -455,10 +455,10 @@ "node": ">=18" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "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" ], @@ -469,10 +469,10 @@ "android" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-android-arm64": { + "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" ], @@ -483,10 +483,10 @@ "android" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-darwin-arm64": { + "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" ], @@ -497,10 +497,10 @@ "darwin" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-darwin-x64": { + "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" ], @@ -511,10 +511,10 @@ "darwin" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "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" ], @@ -525,10 +525,10 @@ "freebsd" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-freebsd-x64": { + "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" ], @@ -539,192 +539,231 @@ "freebsd" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "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": [ "linux" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-openbsd-x64": { + "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" ], @@ -735,10 +774,10 @@ "openbsd" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "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" ], @@ -749,10 +788,10 @@ "openharmony" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "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" ], @@ -763,10 +802,10 @@ "win32" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "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" ], @@ -777,10 +816,10 @@ "win32" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "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" ], @@ -791,10 +830,10 @@ "win32" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "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" ], @@ -805,9 +844,9 @@ "win32" ] }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/chrome": { + "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", @@ -816,16 +855,16 @@ "@types/har-format": "*" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/estree": { + "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" }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/filesystem": { + "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", @@ -833,23 +872,23 @@ "@types/filewriter": "*" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/filewriter": { + "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" }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/@types/har-format": { + "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" }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/esbuild": { + "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, @@ -889,9 +928,9 @@ "@esbuild/win32-x64": "0.25.12" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/fdir": { + "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", @@ -907,9 +946,9 @@ } } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/fsevents": { + "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, @@ -922,9 +961,9 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/nanoid": { + "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": [ @@ -941,17 +980,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/picocolors": { + "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" }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/picomatch": { + "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", "engines": { @@ -961,10 +1000,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -990,10 +1029,10 @@ "node": "^10 || ^12 || >=14" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/rollup": { + "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": { @@ -1007,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" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/source-map-js": { + "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", @@ -1045,15 +1084,15 @@ "node": ">=0.10.0" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/tinyglobby": { + "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" @@ -1062,9 +1101,9 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/node_modules/typescript": { + "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", @@ -1076,10 +1115,10 @@ "node": ">=14.17" } }, - "../../../private/tmp/opencli-sync-feat-scys/extension/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==", + "node_modules/vite": { + "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 From 91c19fa1c74703b34169e072ae52c1015291b1c3 Mon Sep 17 00:00:00 2001 From: warkcod Date: Sun, 19 Apr 2026 13:18:48 +0800 Subject: [PATCH 9/9] Unblock browser adapters on headless CDP sessions The runtime only honored OPENCLI_CDP_ENDPOINT for Electron apps, so regular browser-backed adapters like douban still hard-failed on Browser Bridge even though the docs describe CDP as the fallback path for remote or no-GUI environments. This routes any browser-backed command through CDPBridge when a manual CDP endpoint is provided and locks the behavior with a focused runtime regression test. Constraint: Headless and remote-server flows cannot rely on the Browser Bridge extension, so OPENCLI_CDP_ENDPOINT must work for normal browser adapters as documented Rejected: Keep the override inside executeCommand only | would leave other browserSession entry points inconsistent and keep the docs/runtime mismatch Confidence: high Scope-risk: narrow Directive: If browser factory selection changes again, keep OPENCLI_CDP_ENDPOINT as a top-level override for all browser-backed adapters, not just Electron targets Tested: npx vitest run --project unit src/runtime.test.ts Tested: npx vitest run --project unit src/runtime.test.ts src/browser/page.test.ts src/browser/errors-detach.test.ts Tested: npm run typecheck Tested: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:18902 node dist/src/main.js scys article https://scys.com/articleDetail/xq_topic/14422288551185512 -f json Tested: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:18902 node dist/src/main.js douban top250 --limit 1 -f json Tested: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:18902 /Users/mac/.opencli-scys/bin/opencli douban top250 --limit 1 -f json Not-tested: /Users/mac/.opencli-scys/bin/opencli scys article via CDP on the current 18902 profile because that browser session is not logged into scys --- src/runtime.test.ts | 19 +++++++++++++++++++ src/runtime.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/runtime.test.ts 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; }