diff --git a/Plugin/background.js b/Plugin/background.js index 3b99d6f..03e41cd 100644 --- a/Plugin/background.js +++ b/Plugin/background.js @@ -44,11 +44,12 @@ const MENU_VIDEO_ID = 'redbox-save-video'; const DEFAULT_PLUGIN_SETTINGS = { knowledgeApiBaseUrl: 'http://127.0.0.1:31937', knowledgeApiEndpointPath: '/api/knowledge', - xhsIntervalMinSeconds: 1.5, - xhsIntervalMaxSeconds: 3.5, + xhsIntervalMinSeconds: 3, + xhsIntervalMaxSeconds: 6, xhsBloggerNoteLimit: 50, xhsKeywordNoteLimit: 20, xhsLinkBatchLimit: 50, + xhsBloggerCollectionMode: 'api', saveToRedboxByDefault: true, autoUpdateCheck: true, }; @@ -95,6 +96,18 @@ function pluginError(scope, details) { console.error(`[redbox-plugin][${scope}]`, details); } +function pluginDebug(scope, details) { + console.debug(`[redbox-plugin][debug][${scope}]`, details); +} + +function shouldLogMessageType(type) { + const noisyTypes = new Set([ + 'page-state:update', + 'xhs:get-task-queue', + ]); + return !noisyTypes.has(String(type || '')); +} + function createLinkFallbackPageInfo(overrides = {}) { return { kind: 'generic', @@ -232,12 +245,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { async function handleMessage(message, sender) { const tabContext = await resolveMessageTab(message, sender); const tabId = tabContext.tabId; - pluginLog('handle-message', { - type: message?.type || 'unknown', - tabId: tabId || null, - senderTabUrl: String(sender?.tab?.url || ''), - resolvedTabUrl: String(tabContext.tab?.url || ''), - }); + if (shouldLogMessageType(message?.type)) { + pluginLog('handle-message', { + type: message?.type || 'unknown', + tabId: tabId || null, + senderTabUrl: String(sender?.tab?.url || ''), + resolvedTabUrl: String(tabContext.tab?.url || ''), + }); + } switch (message?.type) { case 'page-state:update': @@ -337,6 +352,8 @@ async function handleMessage(message, sender) { }); case 'xhs:get-task-queue': return { success: true, queue: getXhsTaskQueueState() }; + case 'xhs:control-active-task': + return controlXhsActiveTask(message?.action); case 'xhs:get-history': return await getXhsTaskHistory(); case 'xhs:clear-history': @@ -703,11 +720,48 @@ function normalizePluginSettings(input = {}) { xhsBloggerNoteLimit: Math.round(clampNumber(source.xhsBloggerNoteLimit, 1, 200, DEFAULT_PLUGIN_SETTINGS.xhsBloggerNoteLimit)), xhsKeywordNoteLimit: Math.round(clampNumber(source.xhsKeywordNoteLimit, 1, 50, DEFAULT_PLUGIN_SETTINGS.xhsKeywordNoteLimit)), xhsLinkBatchLimit: Math.round(clampNumber(source.xhsLinkBatchLimit, 1, 50, DEFAULT_PLUGIN_SETTINGS.xhsLinkBatchLimit)), + xhsBloggerCollectionMode: normalizeText(source.xhsBloggerCollectionMode) === 'tab' ? 'tab' : 'api', saveToRedboxByDefault: source.saveToRedboxByDefault !== false, autoUpdateCheck: source.autoUpdateCheck !== false, }; } +function resolveXhsCollectionMode(modeInput, fallback = DEFAULT_PLUGIN_SETTINGS.xhsBloggerCollectionMode) { + return normalizeText(modeInput) === 'tab' + ? 'tab' + : normalizeText(fallback) === 'tab' + ? 'tab' + : 'api'; +} + +function normalizeXhsBloggerCollectOptions(options = {}, settingsInput) { + const settings = normalizePluginSettings(settingsInput || DEFAULT_PLUGIN_SETTINGS); + const source = options && typeof options === 'object' ? options : {}; + const mode = resolveXhsCollectionMode(source.mode, settings.xhsBloggerCollectionMode); + const limit = Math.round(clampNumber(source.limit, 1, 200, settings.xhsBloggerNoteLimit)); + const interval = normalizeXhsCollectInterval({ + minSeconds: Math.max(3, clampNumber( + source?.interval?.minSeconds ?? source?.intervalMinSeconds ?? settings.xhsIntervalMinSeconds, + 3, + XHS_COLLECT_INTERVAL_MAX_MS / 1000, + Math.max(3, settings.xhsIntervalMinSeconds), + )), + maxSeconds: clampNumber( + source?.interval?.maxSeconds ?? source?.intervalMaxSeconds ?? settings.xhsIntervalMaxSeconds, + 3, + XHS_COLLECT_INTERVAL_MAX_MS / 1000, + Math.max(6, settings.xhsIntervalMaxSeconds), + ), + }); + return { + ...source, + mode, + limit, + interval, + saveToRedBox: source.saveToRedBox !== false, + }; +} + async function readPluginSettings() { const result = await getStorageLocal([REDBOX_PLUGIN_SETTINGS_KEY]); return normalizePluginSettings({ @@ -1129,6 +1183,17 @@ function sanitizeXhsTaskForState(task) { updatedAt: normalizeText(task.updatedAt), summary: normalizeText(task.summary), error: normalizeText(task.error), + savedCount: Number(task.savedCount || 0), + paused: task.paused === true, + cancelRequested: task.cancelRequested === true, + progress: task.progress && typeof task.progress === 'object' + ? { + current: Number(task.progress.current || 0), + total: Number(task.progress.total || 0), + message: normalizeText(task.progress.message), + mode: normalizeText(task.progress.mode), + } + : null, }; } @@ -1164,6 +1229,84 @@ function getXhsTaskQueueState() { }; } +function setActiveXhsTaskProgress(progressPatch = {}) { + if (!xhsActiveTask) return getXhsTaskQueueState(); + const previous = xhsActiveTask.progress && typeof xhsActiveTask.progress === 'object' + ? xhsActiveTask.progress + : {}; + xhsActiveTask.progress = { + ...previous, + ...progressPatch, + current: Number(progressPatch.current ?? previous.current ?? 0), + total: Number(progressPatch.total ?? previous.total ?? 0), + message: normalizeText(progressPatch.message ?? previous.message), + mode: normalizeText(progressPatch.mode ?? previous.mode), + }; + xhsActiveTask.updatedAt = new Date().toISOString(); + pluginDebug('xhs-task-progress', { + taskId: xhsActiveTask.id, + type: xhsActiveTask.type, + progress: xhsActiveTask.progress, + }); + return publishXhsTaskQueueState(); +} + +function ensureXhsTaskNotCancelled() { + if (xhsActiveTask?.cancelRequested) { + throw new Error('采集任务已取消'); + } +} + +async function waitIfXhsTaskPaused() { + while (xhsActiveTask?.paused) { + await sleep(250); + ensureXhsTaskNotCancelled(); + } +} + +async function syncXhsTaskStep(progressPatch = {}) { + ensureXhsTaskNotCancelled(); + await waitIfXhsTaskPaused(); + setActiveXhsTaskProgress(progressPatch); +} + +function controlXhsActiveTask(actionInput) { + const action = normalizeText(actionInput); + if (!xhsActiveTask) { + return { + success: false, + error: '当前没有执行中的采集任务', + queue: getXhsTaskQueueState(), + }; + } + if (action === 'pause') { + xhsActiveTask.paused = true; + } else if (action === 'resume') { + xhsActiveTask.paused = false; + } else if (action === 'cancel') { + xhsActiveTask.cancelRequested = true; + xhsActiveTask.paused = false; + } else { + return { + success: false, + error: '不支持的任务控制动作', + queue: getXhsTaskQueueState(), + }; + } + xhsActiveTask.updatedAt = new Date().toISOString(); + pluginDebug('xhs-task-control', { + taskId: xhsActiveTask.id, + type: xhsActiveTask.type, + action, + paused: xhsActiveTask.paused === true, + cancelRequested: xhsActiveTask.cancelRequested === true, + }); + return { + success: true, + queue: publishXhsTaskQueueState(), + }; +} + function summarizeXhsTaskResult(result) { if (result?.mode === 'xhs-link-batch' || result?.mode === 'xhs-blogger-notes') { return `成功 ${Number(result.count || 0)} 条,失败 ${Number(result.failed || 0)} 条`; @@ -1237,7 +1380,10 @@ function buildXhsTaskLogMessage(task, status, result, error) { } if (status === 'failed') { const detail = error instanceof Error ? error.message : normalizeText(error); - return `${action}失败${detail ? `:${detail}` : ''}`; + const progress = task?.progress?.total + ? `(进度 ${Number(task.progress.current || 0)}/${Number(task.progress.total || 0)})` + : ''; + return `${action}失败${progress}${detail ? `:${detail}` : ''}`; } const summary = normalizeText(result?.task?.summary || result?.summary || summarizeXhsTaskResult(result)); if (status === 'partial') { @@ -1246,6 +1392,22 @@ function buildXhsTaskLogMessage(task, status, result, error) { return `${action}成功:${summary}`; } +function setActiveXhsTaskSavedCount(value) { + if (!xhsActiveTask) return; + xhsActiveTask.savedCount = Math.max(0, Number(value || 0)); + xhsActiveTask.updatedAt = new Date().toISOString(); +} + +function describeBloggerCollectOptions(options = {}) { + return { + mode: normalizeText(options?.mode) || 'api', + limit: Number(options?.limit || 0), + intervalMinSeconds: Number(options?.interval?.minMs || 0) / 1000, + intervalMaxSeconds: Number(options?.interval?.maxMs || 0) / 1000, + saveToRedBox: options?.saveToRedBox !== false, + }; +} + async function hydrateXhsTaskLogs() { const stored = await getStorageLocal([XHS_TASK_LOG_KEY]).catch(() => ({})); const logs = Array.isArray(stored?.[XHS_TASK_LOG_KEY]) ? stored[XHS_TASK_LOG_KEY] : []; @@ -1268,6 +1430,11 @@ function appendXhsTaskLog(entry) { function publishXhsTaskQueueState() { const queue = getXhsTaskQueueState(); + pluginDebug('xhs-task-queue-state', { + active: queue.active, + queuedCount: queue.queuedCount, + running: queue.running, + }); void setStorageLocal({ [XHS_TASK_QUEUE_STATE_KEY]: queue }).catch((error) => { pluginWarn('xhs-task-queue-store-failed', { error: describeError(error) }); }); @@ -1284,6 +1451,10 @@ function enqueueXhsTask({ type, title, tabId, execute }) { title, tabId, status: 'queued', + savedCount: 0, + paused: false, + cancelRequested: false, + progress: null, createdAt: now, updatedAt: now, execute, @@ -1291,6 +1462,13 @@ function enqueueXhsTask({ type, title, tabId, execute }) { reject, }; xhsTaskQueue.push(task); + pluginDebug('xhs-task-enqueue', { + taskId: task.id, + type, + title, + tabId, + queuedCount: xhsTaskQueue.length, + }); publishXhsTaskQueueState(); void runNextXhsTask(); }); @@ -1301,8 +1479,18 @@ async function runNextXhsTask() { const task = xhsTaskQueue.shift(); xhsActiveTask = task; task.status = 'running'; + task.savedCount = 0; + task.paused = false; + task.cancelRequested = false; + task.progress = null; task.startedAt = new Date().toISOString(); task.updatedAt = task.startedAt; + pluginDebug('xhs-task-start', { + taskId: task.id, + type: task.type, + title: task.title, + tabId: task.tabId, + }); appendXhsTaskLog({ taskId: task.id, type: task.type, @@ -1314,6 +1502,11 @@ async function runNextXhsTask() { publishXhsTaskQueueState(); try { const result = await task.execute(); + pluginDebug('xhs-task-finish', { + taskId: task.id, + type: task.type, + result, + }); const logStatus = classifyXhsTaskResult(result); task.status = logStatus === 'success' ? 'completed' : logStatus; task.summary = summarizeXhsTaskResult(result); @@ -1333,17 +1526,31 @@ async function runNextXhsTask() { taskQueue: getXhsTaskQueueState(), }); } catch (error) { - task.status = 'failed'; - task.error = error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); + const cancelled = /已取消/.test(message); + const savedCount = Math.max(0, Number(task.savedCount || 0)); + pluginWarn('xhs-task-failed', { + taskId: task.id, + type: task.type, + title: task.title, + error: message, + savedCount, + progress: task.progress || null, + }); + task.status = cancelled ? 'cancelled' : 'failed'; + task.error = message; + task.summary = cancelled ? `采集已取消,已保存 ${savedCount} 条` : ''; task.completedAt = new Date().toISOString(); task.updatedAt = task.completedAt; xhsLastTask = task; appendXhsTaskLog({ taskId: task.id, type: task.type, - status: 'failed', + status: cancelled ? 'partial' : 'failed', title: task.title, - message: buildXhsTaskLogMessage(task, 'failed', null, error), + message: cancelled + ? `${getXhsTaskActionLabel(task.type)}已取消,已保存 ${savedCount} 条` + : buildXhsTaskLogMessage(task, 'failed', null, error), createdAt: task.completedAt, }); task.reject(error); @@ -3083,12 +3290,148 @@ async function collectXhsBloggerFromTab(tabId) { }; } +function parseXhsNoteUrl(urlInput) { + try { + const parsed = new URL(String(urlInput || '')); + const match = parsed.pathname.match(/\/(?:explore|discovery\/item)\/([A-Za-z0-9]+)/); + if (!match?.[1]) return null; + return { + id: match[1], + token: normalizeText(parsed.searchParams.get('xsec_token')), + source: normalizeText(parsed.searchParams.get('xsec_source')) || 'pc_user', + href: parsed.toString(), + }; + } catch { + return null; + } +} + +function pickXhsImageUrl(image) { + if (!image) return ''; + if (typeof image === 'string') return normalizeText(image); + if (Array.isArray(image)) { + for (const item of image) { + const url = pickXhsImageUrl(item); + if (url) return url; + } + return ''; + } + return normalizeText( + image.urlDefault + || image.url_default + || image.urlPre + || image.url_pre + || image.url + || image.info_list?.[0]?.url + || image.infoList?.[0]?.url + || image.src, + ); +} + +function pickXhsVideoUrl(video) { + const stream = video?.media?.stream || video?.consumer?.origin_video_key || video?.stream || null; + if (!stream || typeof stream !== 'object') return ''; + const groups = [ + ...(Array.isArray(stream.h265) ? stream.h265 : []), + ...(Array.isArray(stream.h_265) ? stream.h_265 : []), + ...(Array.isArray(stream.h264) ? stream.h264 : []), + ...(Array.isArray(stream.h_264) ? stream.h_264 : []), + ...(Array.isArray(stream.av1) ? stream.av1 : []), + ].filter(Boolean); + groups.sort((left, right) => Number(right?.size || 0) - Number(left?.size || 0)); + return normalizeText(groups[0]?.master_url || groups[0]?.backup_url || ''); +} + +function buildXhsNotePayloadFromFeed(feedResult, fallback = {}) { + const rawItems = Array.isArray(feedResult?.items) + ? feedResult.items + : Array.isArray(feedResult?.data?.items) + ? feedResult.data.items + : Array.isArray(feedResult?.result?.items) + ? feedResult.result.items + : []; + const firstItem = rawItems[0] || null; + const noteCard = + firstItem?.note_card + || firstItem?.noteCard + || firstItem?.item?.note_card + || firstItem?.item?.noteCard + || feedResult?.note_card + || feedResult?.noteCard + || null; + if (!noteCard) { + pluginWarn('xhs-feed-shape-unexpected', { + fallback, + feedResultType: typeof feedResult, + feedResultKeys: feedResult && typeof feedResult === 'object' ? Object.keys(feedResult).slice(0, 20) : [], + firstItemKeys: firstItem && typeof firstItem === 'object' ? Object.keys(firstItem).slice(0, 20) : [], + sample: firstItem && typeof firstItem === 'object' + ? { + model_type: firstItem.model_type, + note_card: Boolean(firstItem.note_card), + noteCard: Boolean(firstItem.noteCard), + item: Boolean(firstItem.item), + } + : null, + }); + throw new Error(`未获取到笔记详情接口数据(keys: ${(feedResult && typeof feedResult === 'object' ? Object.keys(feedResult).slice(0, 6).join(',') : 'none') || 'none'})`); + } + const noteId = normalizeText(noteCard.note_id || noteCard.noteId || fallback.id); + const source = normalizeText(fallback.href) || `https://www.xiaohongshu.com/explore/${noteId}`; + const images = Array.isArray(noteCard.image_list) + ? noteCard.image_list.map((item) => pickXhsImageUrl(item)).filter(Boolean) + : []; + return { + source, + noteId, + noteType: normalizeText(noteCard.type) === 'video' ? 'video' : 'image', + title: normalizeText(noteCard.title), + text: normalizeText(noteCard.desc), + content: normalizeText(noteCard.desc), + author: normalizeText(noteCard.user?.nickname), + authorProfileUrl: noteCard.user?.user_id + ? `https://www.xiaohongshu.com/user/profile/${noteCard.user.user_id}` + : '', + coverUrl: images[0] || pickXhsImageUrl(noteCard.cover) || '', + images, + videoUrl: pickXhsVideoUrl(noteCard.video), + stats: { + likes: Number(noteCard.interact_info?.liked_count || 0), + collects: Number(noteCard.interact_info?.collected_count || 0), + }, + }; +} + async function collectXhsBloggerNotesFromTab(tabId, options = {}) { - const settings = await readPluginSettings(); - const limit = Math.max(1, Math.min(Number(options?.limit || settings.xhsBloggerNoteLimit), 200)); + pluginLog('xhs-blogger-notes-dispatch', { + tabId, + rawOptions: options || {}, + }); const payload = await runExtraction(tabId, extractXhsBloggerNotesPayload, { world: 'MAIN', - args: [limit, normalizeText(options?.mode) || 'auto'], + args: [Number(options?.limit || 50), normalizeText(options?.mode) === 'tab' ? 'rpa' : 'api'], + }); + return await collectXhsBloggerNotesByMode(tabId, payload, options); +} + +async function collectXhsBloggerNotesByMode(tabId, payload, options = {}) { + const settings = await readPluginSettings(); + const normalizedOptions = normalizeXhsBloggerCollectOptions(options, settings); + pluginLog('xhs-blogger-notes-payload', { + tabId, + userId: normalizeText(payload?.userId), + nickname: normalizeText(payload?.nickname), + extractedNotes: Array.isArray(payload?.notes) ? payload.notes.length : 0, + extractedUrls: Array.isArray(payload?.urls) ? payload.urls.length : 0, + payloadCollectionMode: normalizeText(payload?.collectionMode), + payloadApiError: normalizeText(payload?.apiError), + options: describeBloggerCollectOptions(normalizedOptions), + }); + appendXhsTaskLog({ + type: 'xhs:collect-blogger-notes', + status: 'running', + title: `采集当前博主笔记(${normalizedOptions.limit} 条)`, + message: `模式 ${normalizedOptions.mode === 'tab' ? '传统 Tab' : 'API'},识别到 ${Array.isArray(payload?.urls) ? payload.urls.length : 0} 条候选笔记`, }); const urls = Array.from(new Set(Array.isArray(payload?.urls) ? payload.urls.map((url) => normalizeText(url)).filter(Boolean) @@ -3097,9 +3440,29 @@ async function collectXhsBloggerNotesFromTab(tabId, options = {}) { const reason = normalizeText(payload?.apiError); throw new Error(reason || '当前博主页未识别到可采集的笔记,请确认已登录并滚动加载主页笔记'); } + if (normalizedOptions.mode === 'tab') { + return await collectXhsBloggerNotesWithTabs(payload, urls, normalizedOptions); + } + return await collectXhsBloggerNotesViaApi(tabId, payload, normalizedOptions); +} + +async function collectXhsBloggerNotesWithTabs(payload, urls, options = {}) { const titleName = normalizeText(payload?.nickname) || normalizeText(payload?.userId) || '小红书博主'; + pluginLog('xhs-blogger-notes-tab-mode', { + blogger: titleName, + userId: normalizeText(payload?.userId), + urlCount: urls.length, + options: describeBloggerCollectOptions(options), + }); + appendXhsTaskLog({ + type: 'xhs:collect-blogger-notes', + status: 'running', + title: `采集当前博主笔记(${urls.length} 条)`, + message: `传统模式启动:${titleName},待打开 ${urls.length} 个详情页`, + }); const response = await collectXhsNoteLinks(urls, { ...options, + mode: 'tab', limit: urls.length, taskType: 'blogger-notes', taskTitle: `博主笔记采集:${titleName}`, @@ -3119,6 +3482,166 @@ async function collectXhsBloggerNotesFromTab(tabId, options = {}) { }; } +async function collectXhsBloggerNotesViaApi(tabId, payload, options = {}) { + const notes = Array.isArray(payload?.notes) ? payload.notes : []; + const titleName = normalizeText(payload?.nickname) || normalizeText(payload?.userId) || '小红书博主'; + const targetNotes = notes + .map((item) => ({ + ...item, + urlInfo: parseXhsNoteUrl(item?.url), + })) + .filter((item) => item.urlInfo?.id) + .slice(0, options.limit); + if (targetNotes.length === 0) { + throw new Error('当前博主页未识别到可用于 API 采集的笔记链接'); + } + pluginLog('xhs-blogger-notes-api-mode', { + tabId, + blogger: titleName, + userId: normalizeText(payload?.userId), + candidateNotes: notes.length, + targetNotes: targetNotes.length, + options: describeBloggerCollectOptions(options), + }); + appendXhsTaskLog({ + type: 'xhs:collect-blogger-notes', + status: 'running', + title: `采集当前博主笔记(${targetNotes.length} 条)`, + message: `API 模式启动:${titleName},准备采集 ${targetNotes.length} 条`, + }); + + const results = []; + const failures = []; + await syncXhsTaskStep({ + current: 0, + total: targetNotes.length, + message: `准备采集 ${titleName} 的笔记`, + mode: 'api', + }); + + for (let index = 0; index < targetNotes.length; index += 1) { + const note = targetNotes[index]; + pluginLog('xhs-blogger-notes-api-item-start', { + blogger: titleName, + index: index + 1, + total: targetNotes.length, + noteId: normalizeText(note?.urlInfo?.id), + url: normalizeText(note?.urlInfo?.href), + }); + await syncXhsTaskStep({ + current: results.length + failures.length, + total: targetNotes.length, + message: `API 模式采集中 ${index + 1}/${targetNotes.length}`, + mode: 'api', + }); + let intervalMs = 0; + try { + if (index > 0) { + intervalMs = await sleepXhsCollectInterval(options.interval); + } + const feedResult = await runExtraction(tabId, extractXhsNoteFeedByUrlFromCurrentPage, { + world: 'MAIN', + args: [note.urlInfo.href, note.urlInfo.id], + }); + const entryPayload = buildXhsNotePayloadFromFeed(feedResult, note.urlInfo); + const response = options.saveToRedBox !== false ? await postKnowledgeEntry(buildXhsEntry(entryPayload)) : null; + results.push({ + url: note.urlInfo.href, + title: normalizeText(entryPayload.title) || note.urlInfo.href, + noteId: entryPayload.noteId, + entryId: response?.entryId || '', + duplicate: Boolean(response?.duplicate), + intervalMs, + }); + setActiveXhsTaskSavedCount(results.length); + pluginLog('xhs-blogger-notes-api-item-success', { + blogger: titleName, + index: index + 1, + total: targetNotes.length, + noteId: entryPayload.noteId, + title: normalizeText(entryPayload.title), + entryId: response?.entryId || '', + duplicate: Boolean(response?.duplicate), + intervalMs, + }); + setActiveXhsTaskProgress({ + current: results.length + failures.length, + total: targetNotes.length, + message: `已采集 ${results.length + failures.length}/${targetNotes.length}`, + mode: 'api', + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + failures.push({ + url: note.urlInfo.href, + error: errorMessage, + intervalMs, + }); + pluginWarn('xhs-blogger-notes-api-item-failed', { + blogger: titleName, + index: index + 1, + total: targetNotes.length, + noteId: normalizeText(note?.urlInfo?.id), + url: normalizeText(note?.urlInfo?.href), + intervalMs, + error: errorMessage, + }); + appendXhsTaskLog({ + type: 'xhs:collect-blogger-notes', + status: 'partial', + title: `采集当前博主笔记(${targetNotes.length} 条)`, + message: `第 ${index + 1}/${targetNotes.length} 条失败:${errorMessage}`, + }); + setActiveXhsTaskProgress({ + current: results.length + failures.length, + total: targetNotes.length, + message: `已采集 ${results.length + failures.length}/${targetNotes.length}`, + mode: 'api', + }); + } + } + + const historyItem = await appendXhsTaskHistory({ + id: `xhs-blogger-api-${hashString(`${titleName}-${Date.now()}`)}`, + type: 'blogger-notes', + title: `博主笔记采集:${titleName}`, + status: failures.length > 0 ? (results.length > 0 ? 'partial' : 'failed') : 'completed', + count: results.length, + failed: failures.length, + summary: `成功 ${results.length} 条,失败 ${failures.length} 条;API 模式;采集间隔 ${formatXhsCollectInterval(options.interval)}`, + payload: { results, failures, interval: options.interval, mode: 'api' }, + }); + pluginLog('xhs-blogger-notes-api-finished', { + blogger: titleName, + successCount: results.length, + failedCount: failures.length, + failures: failures.slice(0, 5), + interval: describeBloggerCollectOptions(options), + }); + + return { + success: true, + mode: 'xhs-blogger-notes', + completed: failures.length === 0, + count: results.length, + failed: failures.length, + results, + failures, + interval: options.interval, + task: historyItem, + blogger: { + userId: normalizeText(payload?.userId), + nickname: titleName, + source: normalizeText(payload?.source), + noteCount: Number(payload?.noteCount || 0), + collectedUrlCount: targetNotes.length, + apiError: normalizeText(payload?.apiError), + collectionMode: 'api', + }, + error: failures.length > 0 ? `API 模式采集完成,但有 ${failures.length} 条失败` : undefined, + }; +} + function normalizeXhsCollectUrls(input) { const values = Array.isArray(input) ? input @@ -3164,7 +3687,38 @@ async function collectXhsNoteLinks(urlsInput, options = {}) { const failures = []; const targetUrls = urls.slice(0, limit); + pluginLog('xhs-note-links-start', { + totalUrls: urls.length, + targetUrls: targetUrls.length, + options: { + mode: normalizeText(options?.mode) || 'tab', + limit, + intervalMinSeconds: Number(interval.minMs || 0) / 1000, + intervalMaxSeconds: Number(interval.maxMs || 0) / 1000, + saveToRedBox: shouldSave, + taskType: normalizeText(options?.taskType), + taskTitle: normalizeText(options?.taskTitle), + }, + }); + await syncXhsTaskStep({ + current: 0, + total: targetUrls.length, + message: '准备采集笔记详情', + mode: normalizeText(options?.mode) || 'tab', + }); for (let index = 0; index < targetUrls.length; index += 1) { + pluginLog('xhs-note-links-item-start', { + index: index + 1, + total: targetUrls.length, + url: targetUrls[index], + mode: normalizeText(options?.mode) || 'tab', + }); + await syncXhsTaskStep({ + current: results.length + failures.length, + total: targetUrls.length, + message: `正在采集第 ${index + 1}/${targetUrls.length} 条笔记`, + mode: normalizeText(options?.mode) || 'tab', + }); const url = targetUrls[index]; let tab = null; let intervalMs = 0; @@ -3192,11 +3746,49 @@ async function collectXhsNoteLinks(urlsInput, options = {}) { duplicate: Boolean(response?.duplicate), intervalMs, }); + setActiveXhsTaskSavedCount(results.length); + pluginLog('xhs-note-links-item-success', { + index: index + 1, + total: targetUrls.length, + url, + noteId: payload?.noteId || '', + title: normalizeText(payload?.title), + entryId: response?.entryId || '', + duplicate: Boolean(response?.duplicate), + intervalMs, + }); + setActiveXhsTaskProgress({ + current: results.length + failures.length, + total: targetUrls.length, + message: `已完成 ${results.length + failures.length}/${targetUrls.length}`, + mode: normalizeText(options?.mode) || 'tab', + }); } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); failures.push({ url, - error: error instanceof Error ? error.message : String(error), + error: errorMessage, + intervalMs, + }); + pluginWarn('xhs-note-links-item-failed', { + index: index + 1, + total: targetUrls.length, + url, intervalMs, + error: errorMessage, + mode: normalizeText(options?.mode) || 'tab', + }); + appendXhsTaskLog({ + type: normalizeText(options?.taskType) === 'blogger-notes' ? 'xhs:collect-blogger-notes' : 'xhs:collect-note-links', + status: 'partial', + title: normalizeText(options?.taskTitle) || '批量采集', + message: `第 ${index + 1}/${targetUrls.length} 条失败:${errorMessage}`, + }); + setActiveXhsTaskProgress({ + current: results.length + failures.length, + total: targetUrls.length, + message: `已完成 ${results.length + failures.length}/${targetUrls.length}`, + mode: normalizeText(options?.mode) || 'tab', }); } finally { if (tab?.id) { @@ -3215,6 +3807,12 @@ async function collectXhsNoteLinks(urlsInput, options = {}) { summary: `成功 ${results.length} 条,失败 ${failures.length} 条;采集间隔 ${formatXhsCollectInterval(interval)}`, payload: { results, failures, interval }, }); + pluginLog('xhs-note-links-finished', { + completed: results.length, + failed: failures.length, + failures: failures.slice(0, 5), + mode: normalizeText(options?.mode) || 'tab', + }); return { success: true, @@ -5580,6 +6178,274 @@ async function extractXhsBloggerNotesPayload(limitInput = 50, modeInput = 'auto' }; } +async function extractXhsNoteFeedByUrlFromCurrentPage(targetUrlInput, noteIdInput) { + function normalizeText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function parseTarget(inputUrl, inputId) { + try { + const parsed = new URL(String(inputUrl || ''), location.href); + const match = parsed.pathname.match(/\/(?:explore|discovery\/item)\/([A-Za-z0-9]+)/); + return { + noteId: normalizeText(inputId) || normalizeText(match?.[1]), + url: parsed.toString(), + token: normalizeText(parsed.searchParams.get('xsec_token')), + source: normalizeText(parsed.searchParams.get('xsec_source')) || 'pc_user', + }; + } catch { + return { + noteId: normalizeText(inputId), + url: '', + token: '', + source: 'pc_user', + }; + } + } + + function readFeedFromStore(noteId) { + const store = Array.isArray(window.__REDBOX_XHS_RESPONSES__) ? window.__REDBOX_XHS_RESPONSES__ : []; + for (let index = store.length - 1; index >= 0; index -= 1) { + const record = store[index]; + let parsed; + try { + parsed = new URL(record?.url || '', location.href); + } catch { + continue; + } + if (parsed.pathname !== '/api/sns/web/v1/feed') continue; + const data = record?.result?.data || record?.result; + const noteCard = data?.items?.[0]?.note_card; + const currentId = normalizeText(noteCard?.note_id || noteCard?.noteId); + if (currentId && currentId === noteId) { + return data; + } + } + return null; + } + + function xB3TraceId() { + let value = ''; + for (let index = 0; index < 16; index += 1) { + value += 'abcdef0123456789'.charAt(Math.floor(Math.random() * 16)); + } + return value; + } + + function traceId() { + const random = (bits) => Math.floor(Math.random() * (1 << bits)); + const time = Date.now(); + const part1 = (BigInt(time) << 23n) | BigInt(random(23)); + const part2 = (BigInt(random(32)) << 32n) | BigInt(random(32)); + return part1.toString(16).padStart(16, '0') + part2.toString(16).padStart(16, '0'); + } + + function crc32(value) { + const bytes = typeof value === 'string' ? Array.from(new TextEncoder().encode(value)) : Array.from(value || []); + let crc = -1; + for (const byte of bytes) { + crc ^= byte; + for (let index = 0; index < 8; index += 1) { + crc = (crc & 1) ? ((crc >>> 1) ^ 0xedb88320) : (crc >>> 1); + } + } + return ((crc ^ -1) >>> 0); + } + + function customBase64(inputBytes) { + const alphabet = 'ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5'; + const bytes = Array.isArray(inputBytes) ? inputBytes : Array.from(inputBytes || []); + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const byte1 = bytes[index]; + const byte2 = index + 1 < bytes.length ? bytes[index + 1] : NaN; + const byte3 = index + 2 < bytes.length ? bytes[index + 2] : NaN; + const triplet = (byte1 << 16) | ((Number.isNaN(byte2) ? 0 : byte2) << 8) | (Number.isNaN(byte3) ? 0 : byte3); + output += alphabet[(triplet >>> 18) & 63]; + output += alphabet[(triplet >>> 12) & 63]; + output += Number.isNaN(byte2) ? '=' : alphabet[(triplet >>> 6) & 63]; + output += Number.isNaN(byte3) ? '=' : alphabet[triplet & 63]; + } + return output; + } + + function getCookie(name) { + const cookies = document.cookie.split(';'); + for (const item of cookies) { + const cookie = item.trim(); + if (cookie.startsWith(`${name}=`)) { + return cookie.slice(name.length + 1); + } + } + return ''; + } + + function getOS() { + const userAgent = window.navigator?.userAgent?.toLowerCase() || ''; + if (userAgent.includes('android')) return 'Android'; + if (userAgent.includes('iphone') || userAgent.includes('ipad') || userAgent.includes('ipod')) return 'iOS'; + if (userAgent.includes('macintosh')) return 'Mac OS'; + if (userAgent.includes('windows')) return 'Windows'; + if (userAgent.includes('linux')) return 'Linux'; + return 'PC'; + } + + function getPlatform(os) { + switch (os) { + case 'Windows': + return 0; + case 'Android': + return 2; + case 'iOS': + return 1; + case 'Mac OS': + return 3; + case 'Linux': + return 4; + default: + return 5; + } + } + + function getXSCommon() { + const b1 = localStorage.getItem('b1') || ''; + const b1b1 = localStorage.getItem('b1b1') || '1'; + const os = getOS(); + const payload = { + s0: getPlatform(os), + s1: '', + x0: b1b1, + x1: '4.2.6', + x2: os, + x3: 'xhs-pc-web', + x4: '4.83.1', + x5: getCookie('a1'), + x6: '', + x7: '', + x8: b1, + x9: crc32(`${b1}`), + x10: 0, + x11: 'normal', + }; + return customBase64(new TextEncoder().encode(JSON.stringify(payload))); + } + + async function seccoreSign(path, body) { + if (typeof window.mnsv2 !== 'function') { + throw new Error('当前页面缺少 window.mnsv2,无法生成小红书签名'); + } + if (typeof window.md5 !== 'function') { + throw new Error('当前页面缺少 window.md5,无法生成小红书签名'); + } + let content = path; + const tag = Object.prototype.toString.call(body); + if (tag === '[object Object]' || tag === '[object Array]') { + content += JSON.stringify(body); + } else if (typeof body === 'string') { + content += body; + } + const contentMd5 = window.md5(content); + const pathMd5 = window.md5(path); + const signature = await window.mnsv2(content, contentMd5, pathMd5); + const payload = { + x0: '4.2.6', + x1: 'xhs-pc-web', + x2: window.xsecplatform || 'PC', + x3: signature, + x4: body ? typeof body : '', + }; + return `XYS_${customBase64(new TextEncoder().encode(JSON.stringify(payload)))}`; + } + + async function requestFeed(target) { + const body = { + source_note_id: target.noteId, + image_formats: ['jpg', 'webp', 'avif'], + extra: { need_body_topic: '1' }, + xsec_source: target.source || 'pc_user', + xsec_token: target.token, + }; + const path = '/api/sns/web/v1/feed'; + const headers = { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/json;charset=UTF-8', + 'x-s': await seccoreSign(path, body), + 'x-t': `${Date.now()}`, + 'x-s-common': getXSCommon(), + 'x-xray-traceid': traceId(), + 'x-b3-traceid': xB3TraceId(), + }; + console.debug('[redbox-plugin][debug][xhs-feed-request]', { + noteId: target.noteId, + source: target.source, + hasToken: Boolean(target.token), + }); + const response = await window.fetch(`https://edith.xiaohongshu.com${path}`, { + method: 'POST', + credentials: 'include', + headers, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`feed HTTP ${response.status}`); + } + const json = await response.json(); + console.warn('[redbox-plugin][debug][xhs-feed-response-shape]', { + noteId: target.noteId, + status: response.status, + topLevelKeys: json && typeof json === 'object' ? Object.keys(json).slice(0, 20) : [], + success: json?.success, + code: json?.code, + msg: json?.msg, + hasData: Boolean(json?.data), + dataKeys: json?.data && typeof json.data === 'object' ? Object.keys(json.data).slice(0, 20) : [], + itemCount: Array.isArray(json?.data?.items) ? json.data.items.length : (Array.isArray(json?.items) ? json.items.length : 0), + firstItemKeys: Array.isArray(json?.data?.items) && json.data.items[0] && typeof json.data.items[0] === 'object' + ? Object.keys(json.data.items[0]).slice(0, 20) + : Array.isArray(json?.items) && json.items[0] && typeof json.items[0] === 'object' + ? Object.keys(json.items[0]).slice(0, 20) + : [], + }); + if (!json) { + throw new Error('小红书 feed 接口返回为空'); + } + if (json.success === false) { + throw new Error(normalizeText(json.msg) || '小红书 feed 接口请求失败'); + } + return json.data || json.result?.data || json; + } + + const target = parseTarget(targetUrlInput, noteIdInput); + if (!target.noteId) { + throw new Error('未识别到目标笔记 ID'); + } + console.debug('[redbox-plugin][debug][xhs-feed-extract]', { + target, + location: location.href, + }); + + const cached = readFeedFromStore(target.noteId); + if (cached) { + console.debug('[redbox-plugin][debug][xhs-feed-extract-cache-hit]', { + noteId: target.noteId, + }); + return cached; + } + if (!target.token) { + console.warn('[redbox-plugin][debug][xhs-feed-extract-token-missing]', { + target, + location: location.href, + }); + throw new Error('目标笔记链接缺少 xsec_token,无法直接请求详情接口'); + } + const feed = await requestFeed(target); + console.debug('[redbox-plugin][debug][xhs-feed-extract-success]', { + noteId: target.noteId, + mode: 'direct-fetch', + }); + return feed; +} + function extractXhsVisibleNoteLinksPayload() { function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); diff --git a/Plugin/docs/xhs-blogger-notes-fix-prompt.md b/Plugin/docs/xhs-blogger-notes-fix-prompt.md new file mode 100644 index 0000000..bf0a93f --- /dev/null +++ b/Plugin/docs/xhs-blogger-notes-fix-prompt.md @@ -0,0 +1,171 @@ +# 提示词:重构小红书笔记采集功能(API 模式) + +## 背景 + +当前 RedBox Capture 插件的"采集博主笔记"功能采用逐个打开 Tab 的方式,速度极慢。需要参考 social-media-copilot 的实现,将其重构为 API 调用模式,同时保留传统 Tab 模式作为备选。 + +## 核心目标 + +1. **新增 API 模式采集**:通过小红书 API 直接获取博主笔记数据,无需逐个打开 Tab,大幅提升采集速度 +2. **保留传统模式**:用户可选择使用原来的 Tab 模式(更稳定但慢) +3. **迁移配置页面**:删除 settings 页面的"小红书采集"配置,改为在插件主页面(sidepanel)采集时动态配置 +4. **支持任务控制**:可暂停、继续、取消采集任务 +5. **进度回调**:采集过程实时显示进度 + +## 参考资源 + +- `social-media-copilot-main/src/entrypoints/xhs.content/tasks/author-post/processor.ts` — 博主笔记采集核心逻辑 +- `social-media-copilot-main/src/entrypoints/xhs.content/api/request.ts` — API 签名和请求头构造 +- `social-media-copilot-main/src/entrypoints/xhs.content/api/user.ts` — webV1UserPosted API +- `social-media-copilot-main/src/entrypoints/xhs.content/api/note.ts` — webV1Feed API +- `/Users/chenshengguang/Documents/程序代码/蘑菇小红书创作/RedBox/Plugin/docs/xhs-capture-principle.md` — 当前采集原理文档 + +## 技术方案 + +### 架构变更 + +``` +传统模式(慢): API 模式(快): +profile tab → 获取笔记链接列表 profile tab → 获取笔记列表 + 逐条调用 feed API + → 循环遍历每个链接 → 所有操作在当前页面内完成 + → 打开新 tab → 无需打开/关闭 tab + → 等待加载完成 → 随机间隔 3~X 秒 + → 提取数据 → 实时进度回调 + → 关闭 tab → 支持取消/暂停 +``` + +### API 签名(必须参考 social-media-copilot 实现) + +#### 请求头格式 + +``` +x-s: mnsv2() 生成,包装为 "XYS_" + base64(JSON) +x-s-common: localStorage[b1] + CRC32 + 固定字段,base64 编码 +x-t: 时间戳毫秒字符串 +x-xray-traceid: 64位混合字符串 +x-b3-traceid: 16位随机十六进制 +``` + +#### 签名函数要求 + +所有签名函数必须内联在注入脚本中(作为嵌套函数),因为是通过 `chrome.scripting.executeScript` 注入到页面执行的,必须是纯 JavaScript,不能依赖外部库。 + +需要实现: +- MD5 哈希函数 +- CRC32 函数(标准 IEEE 802.3) +- 自定义 Base64 编码(XHS 专用查表) +- mnsv2() 调用和结果包装 + +### 文件修改清单 + +| 文件 | 修改内容 | +|------|---------| +| `background.js` | 新增 API 提取函数、签名工具函数、API 收集器、取消支持、消息路由、设置默认值 | +| `sidepanel.html` | 新增博主笔记采集配置面板(模式/条数/间隔) | +| `sidepanel.js` | 配置面板逻辑、options 参数传递、进度监听、取消按钮 | +| `sidepanel.css` | 配置面板样式 | +| `settings.html` | 删除「小红书采集」section | +| `settings.js` | 删除对应 JS 元素引用 | + +## 功能需求 + +### 1. 模式选择(sidepanel 配置面板) + +```html +
+ + + + +
+``` + +- 勾选时:显示「API 模式(更快)」— 直接调 XHS API 获取笔记详情 +- 取消勾选:显示「传统模式(更稳定)」— 逐个打开 Tab 提取 +- 默认使用 API 模式 + +### 2. 默认模式修改 + +`background.js` 中 `DEFAULT_PLUGIN_SETTINGS` 需要添加: +```javascript +xhsBloggerCollectionMode: 'api' // 默认 API 模式 +``` + +### 3. 消息路由修改 + +```javascript +case 'xhs:collect-blogger-notes': + execute: () => { + const options = message?.options || {}; + if (options.mode === 'tab') { + return collectXhsBloggerNotesFromTab(tabId, options); + } + return collectXhsBloggerNotesViaApi(tabId, options); + } +``` + +注意:当前 `message?.options` 可能是 `undefined`,需要确保 sidepanel 正确传递 options 参数。 + +### 4. 任务取消支持 + +- 新增 `xhsActiveTaskAbortController = { aborted: false }` +- `collectXhsBloggerNotesViaApi` 循环中检查取消标志 +- 新增 `cancelXhsActiveTask()` 函数设置 aborted = true +- 任务执行中显示「取消采集」按钮 + +### 5. 进度回调 + +参考 social-media-copilot 的 `TaskProcessor` 模式: +- 通过 `chrome.runtime.sendMessage({ type: 'xhs:task-progress', ... })` 广播进度 +- sidepanel 监听进度消息并更新 UI +- 进度格式:`{ current, total, message }` +- 显示:「已采集 12/50」 + +## API 流程(参考 social-media-copilot) + +### 阶段1:获取博主笔记列表 + +```javascript +// 调用 /api/sns/web/v1/user_posted +// 参数:user_id, cursor, num=20, image_formats +// 分页获取直到达到 limit 数量 +``` + +### 阶段2:获取每条笔记详情 + +```javascript +// 对每条笔记调用 /api/sns/web/v1/feed +// 参数:source_note_id, xsec_token, xsec_source='pc_user' +// 需要 xsec_token(从笔记列表获取) +``` + +### 阶段3:保存到知识库 + +```javascript +// 调用已有的 postKnowledgeEntry(buildXhsEntry(notePayload)) +``` + +## 验证清单 + +1. **API 模式**:打开博主主页 → 选 API 模式 → 点击采集 → 侧栏显示进度 → 笔记保存到知识库 +2. **传统模式**:切换传统模式 → 采集 → 确认 Tab 逐个打开并关闭 +3. **取消**:启动采集 → 点击取消 → 部分已保存的笔记存在 +4. **默认模式**:不设置 options → 走 API 模式 +5. **设置页**:确认「小红书采集」section 已移除 +6. **进度显示**:采集过程中实时显示进度百分比 + +## 已知问题排查 + +如果 API 模式失败,需要检查: +1. `window.mnsv2` 是否可用(XHS 页面全局函数) +2. `localStorage['b1']` 是否有值 +3. `document.cookie` 是否包含 `a1` +4. 签名头格式是否正确(参考 social-media-copilot 的 request.ts) +5. API 响应是否包含 `success: false`(需要解析 msg 字段) + +## 注意事项 + +1. 所有签名代码必须内联在注入函数内(纯 JS,无外部依赖) +2. API 模式需要在已登录小红书的页面执行 +3. 随机间隔至少 3 秒,避免触发反爬机制 +4. 兼容 Tab 模式和 API 模式的结果格式,统一保存到知识库 \ No newline at end of file diff --git a/Plugin/manifest.json b/Plugin/manifest.json index 3816110..e8250d4 100644 --- a/Plugin/manifest.json +++ b/Plugin/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "RedBox Capture", "description": "将网页、链接、选中文字、图片、小红书、抖音、Bilibili、快手、TikTok、Reddit、X、Instagram 和 YouTube 内容保存到 RedBox。", - "version": "1.9.6", + "version": "1.9.7", "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", @@ -62,7 +62,7 @@ "https://www.xiaohongshu.com/*", "https://www.rednote.com/*" ], - "js": ["xhsBridge.js"], + "js": ["vendor/md5.min.js", "xhsBridge.js"], "run_at": "document_start", "world": "MAIN" }, diff --git a/Plugin/package-plugin.sh b/Plugin/package-plugin.sh new file mode 100755 index 0000000..b00ce77 --- /dev/null +++ b/Plugin/package-plugin.sh @@ -0,0 +1,49 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DIST_DIR="$SCRIPT_DIR/dist" +MANIFEST_PATH="$SCRIPT_DIR/manifest.json" + +if [[ ! -f "$MANIFEST_PATH" ]]; then + echo "未找到 manifest.json: $MANIFEST_PATH" >&2 + exit 1 +fi + +VERSION="$(python3 - <<'PY' "$MANIFEST_PATH" +import json +import sys + +manifest_path = sys.argv[1] +with open(manifest_path, "r", encoding="utf-8") as fh: + data = json.load(fh) +version = str(data.get("version", "")).strip() +if not version: + raise SystemExit("manifest.json 缺少 version") +print(version) +PY +)" + +ARCHIVE_NAME="RedBox-Capture-${VERSION}.zip" +OUTPUT_PATH="$DIST_DIR/$ARCHIVE_NAME" +TMP_DIR="$(mktemp -d)" +TMP_ARCHIVE="$TMP_DIR/$ARCHIVE_NAME" + +mkdir -p "$DIST_DIR" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cd "$SCRIPT_DIR" +zip -r "$TMP_ARCHIVE" . \ + -x 'dist/*' \ + -x '.git/*' \ + -x 'node_modules/*' \ + -x '__MACOSX/*' + +mv "$TMP_ARCHIVE" "$OUTPUT_PATH" + +echo "打包完成: $OUTPUT_PATH" diff --git a/Plugin/settings.html b/Plugin/settings.html index 50ad7d8..ee06047 100644 --- a/Plugin/settings.html +++ b/Plugin/settings.html @@ -32,34 +32,6 @@

本地 RedBox

-
-

小红书采集

-
- - -
-
- - -
- -
-

默认行为

+ +
任务队列
diff --git a/Plugin/sidepanel.js b/Plugin/sidepanel.js index 9a38d91..c042027 100644 --- a/Plugin/sidepanel.js +++ b/Plugin/sidepanel.js @@ -14,6 +14,22 @@ const elements = { captureSubtitle: document.getElementById('capture-subtitle'), captureActions: document.getElementById('capture-actions'), captureStatus: document.getElementById('capture-status'), + bloggerNotesPanel: document.getElementById('blogger-notes-panel'), + bloggerNotesModePill: document.getElementById('blogger-notes-mode-pill'), + bloggerNotesApiMode: document.getElementById('blogger-notes-api-mode'), + bloggerNotesModeLabel: document.getElementById('blogger-notes-mode-label'), + bloggerNotesLimit: document.getElementById('blogger-notes-limit'), + bloggerNotesIntervalMax: document.getElementById('blogger-notes-interval-max'), + bloggerNotesStart: document.getElementById('blogger-notes-start'), + bloggerNotesProgress: document.getElementById('blogger-notes-progress'), + bloggerNotesProgressLabel: document.getElementById('blogger-notes-progress-label'), + bloggerNotesProgressPercent: document.getElementById('blogger-notes-progress-percent'), + bloggerNotesProgressFill: document.getElementById('blogger-notes-progress-fill'), + bloggerNotesProgressMeta: document.getElementById('blogger-notes-progress-meta'), + bloggerNotesControls: document.getElementById('blogger-notes-controls'), + bloggerNotesPause: document.getElementById('blogger-notes-pause'), + bloggerNotesResume: document.getElementById('blogger-notes-resume'), + bloggerNotesCancel: document.getElementById('blogger-notes-cancel'), taskQueueBadge: document.getElementById('task-queue-badge'), taskCurrent: document.getElementById('task-current'), taskQueueMeta: document.getElementById('task-queue-meta'), @@ -26,6 +42,19 @@ let refreshing = false; let capturePendingAction = ''; let captureFeedback = null; let captureSignature = ''; +let currentSettings = { + xhsBloggerNoteLimit: 50, + xhsIntervalMaxSeconds: 6, + xhsBloggerCollectionMode: 'api', +}; + +function debugLog(scope, details) { + console.debug(`[redbox-plugin][sidepanel][${scope}]`, details); +} + +function debugWarn(scope, details) { + console.warn(`[redbox-plugin][sidepanel][${scope}]`, details); +} init().catch((error) => { renderConnection(false, error instanceof Error ? error.message : String(error)); @@ -47,6 +76,13 @@ async function init() { function bindEvents() { elements.refresh.addEventListener('click', () => void refreshContext()); elements.openSettings.addEventListener('click', () => chrome.runtime.openOptionsPage()); + elements.bloggerNotesApiMode.addEventListener('change', () => { + renderBloggerNotesMode(); + }); + elements.bloggerNotesStart.addEventListener('click', () => void startBloggerNotesCollection()); + elements.bloggerNotesPause.addEventListener('click', () => void controlBloggerNotesTask('pause')); + elements.bloggerNotesResume.addEventListener('click', () => void controlBloggerNotesTask('resume')); + elements.bloggerNotesCancel.addEventListener('click', () => void controlBloggerNotesTask('cancel')); elements.captureActions.addEventListener('click', (event) => { const button = event.target?.closest?.('button[data-action]'); if (!button) return; @@ -64,8 +100,13 @@ function bindEvents() { }); chrome.runtime?.onMessage?.addListener((message) => { if (message?.type === 'xhs:task-queue:update') { + debugLog('task-queue-update', message.queue || {}); renderTaskQueue(message.queue || {}); renderTaskLogs(message.queue?.logs || []); + renderBloggerNotesPanel({ + ...context, + queue: message.queue || {}, + }); } }); } @@ -83,9 +124,24 @@ async function refreshContext() { refreshing = true; elements.refresh.disabled = true; try { - context = await sendMessage({ type: 'sidepanel:get-context' }); + const [nextContext, settingsResponse] = await Promise.all([ + sendMessage({ type: 'sidepanel:get-context' }), + sendMessage({ type: 'settings:get' }), + ]); + context = nextContext; + currentSettings = { + ...currentSettings, + ...(settingsResponse?.settings || {}), + }; + debugLog('refresh-context', { + context: nextContext, + settings: currentSettings, + }); renderContext(context); } catch (error) { + debugWarn('refresh-context-failed', { + error: error instanceof Error ? error.message : String(error), + }); renderConnection(false, error instanceof Error ? error.message : String(error)); renderPageIdentity({ platform: 'redbox', @@ -105,6 +161,7 @@ function renderContext(nextContext) { renderConnection(Boolean(health.success), health.error || ''); renderPageIdentity(resolvePageIdentity(nextContext)); renderCaptureActions(nextContext); + renderBloggerNotesPanel(nextContext); renderTaskQueue(nextContext?.queue || {}); renderTaskLogs(nextContext?.logs || nextContext?.queue?.logs || []); } @@ -238,6 +295,12 @@ async function runCaptureAction(action) { renderCaptureActions(context); try { const tab = context?.tab || {}; + debugLog('capture-action-start', { + action, + messageType: meta.type, + tabId, + tabUrl: tab.url || '', + }); const response = await sendMessage({ type: meta.type, tabId, @@ -248,12 +311,20 @@ async function runCaptureAction(action) { renderTaskQueue(response.taskQueue); renderTaskLogs(response.taskQueue.logs || []); } + debugLog('capture-action-success', { + action, + response, + }); captureFeedback = { status: 'success', message: summarizeActionResponse(response, meta.done), }; await refreshTaskQueue(false); } catch (error) { + debugWarn('capture-action-failed', { + action, + error: error instanceof Error ? error.message : String(error), + }); captureFeedback = { status: 'error', message: `执行失败:${error instanceof Error ? error.message : String(error)}`, @@ -271,6 +342,206 @@ function renderCaptureStatus(message, status = 'idle') { elements.captureStatus.hidden = !message; } +function renderBloggerNotesMode() { + const apiMode = elements.bloggerNotesApiMode.checked; + elements.bloggerNotesModeLabel.textContent = apiMode ? 'API 模式(更快)' : '传统模式(更稳定)'; + elements.bloggerNotesModePill.textContent = apiMode ? 'API 模式' : '传统模式'; +} + +function applyBloggerNotesSettings() { + if (elements.bloggerNotesPanel.dataset.hydrated === 'true') { + renderBloggerNotesMode(); + return; + } + const defaultMode = String(currentSettings?.xhsBloggerCollectionMode || 'api') !== 'tab'; + elements.bloggerNotesApiMode.checked = defaultMode; + elements.bloggerNotesLimit.value = Number(currentSettings?.xhsBloggerNoteLimit || 50); + elements.bloggerNotesIntervalMax.value = Math.max(3, Number(currentSettings?.xhsIntervalMaxSeconds || 6)); + elements.bloggerNotesPanel.dataset.hydrated = 'true'; + renderBloggerNotesMode(); +} + +function getBloggerNotesOptions() { + const limit = Math.max(1, Math.min(Number(elements.bloggerNotesLimit.value || 50), 200)); + const intervalMaxSeconds = Math.max(3, Math.min(Number(elements.bloggerNotesIntervalMax.value || 6), 60)); + return { + mode: elements.bloggerNotesApiMode.checked ? 'api' : 'tab', + limit, + interval: { + minSeconds: 3, + maxSeconds: intervalMaxSeconds, + }, + }; +} + +async function startBloggerNotesCollection() { + const tabId = Number(context?.tab?.id || 0); + if (!tabId) { + renderBloggerNotesProgress({ + label: '未识别到当前标签页', + meta: '请刷新后重试', + status: 'error', + }); + return; + } + elements.bloggerNotesStart.disabled = true; + renderBloggerNotesProgress({ + label: '正在创建采集任务…', + meta: '准备提交到后台队列', + status: 'pending', + }); + try { + const tab = context?.tab || {}; + const options = getBloggerNotesOptions(); + debugLog('blogger-notes-start', { + tabId, + tabUrl: tab.url || '', + options, + }); + const response = await sendMessage({ + type: 'xhs:collect-blogger-notes', + tabId, + tabUrl: tab.url || '', + windowId: Number(tab.windowId || 0) || undefined, + options, + }); + debugLog('blogger-notes-start-success', response); + if (response.taskQueue) { + renderTaskQueue(response.taskQueue); + renderTaskLogs(response.taskQueue.logs || []); + renderBloggerNotesPanel({ + ...context, + queue: response.taskQueue, + }); + } + renderBloggerNotesProgress({ + label: '采集任务已启动', + meta: summarizeActionResponse(response, '采集任务已加入队列'), + status: 'success', + }); + await refreshTaskQueue(false); + } catch (error) { + debugWarn('blogger-notes-start-failed', { + error: error instanceof Error ? error.message : String(error), + }); + renderBloggerNotesProgress({ + label: '启动失败', + meta: error instanceof Error ? error.message : String(error), + status: 'error', + }); + } finally { + elements.bloggerNotesStart.disabled = false; + } +} + +async function controlBloggerNotesTask(action) { + try { + debugLog('blogger-notes-control', { action }); + const response = await sendMessage({ type: 'xhs:control-active-task', action }); + debugLog('blogger-notes-control-success', response); + renderTaskQueue(response.queue || {}); + renderTaskLogs(response.queue?.logs || []); + renderBloggerNotesPanel({ + ...context, + queue: response.queue || {}, + }); + } catch (error) { + debugWarn('blogger-notes-control-failed', { + action, + error: error instanceof Error ? error.message : String(error), + }); + renderBloggerNotesProgress({ + label: '操作失败', + meta: error instanceof Error ? error.message : String(error), + status: 'error', + }); + } +} + +function renderBloggerNotesProgress({ + label = '', + status = 'idle', + current = 0, + total = 0, + meta = '', +} = {}) { + const safeTotal = Math.max(Number(total || 0), 0); + const safeCurrent = Math.max(Number(current || 0), 0); + const percentage = safeTotal > 0 ? Math.max(0, Math.min(100, Math.round((safeCurrent / safeTotal) * 100))) : 0; + const hasContent = Boolean(label || meta || status === 'pending' || status === 'success' || status === 'error'); + elements.bloggerNotesProgress.dataset.state = status; + elements.bloggerNotesProgress.hidden = !hasContent; + elements.bloggerNotesProgressLabel.textContent = label || '准备开始采集'; + elements.bloggerNotesProgressPercent.textContent = `${percentage}%`; + elements.bloggerNotesProgressFill.style.width = `${percentage}%`; + elements.bloggerNotesProgressMeta.textContent = meta || (safeTotal > 0 ? `已完成 ${safeCurrent} / ${safeTotal}` : '等待任务开始'); +} + +function renderBloggerNotesPanel(nextContext) { + const tab = nextContext?.tab || {}; + const pageInfo = nextContext?.pageInfo || {}; + const identity = nextContext?.pageIdentity || {}; + const platform = normalizePlatform(identity.platform || pageInfo.platform || tab.hostname || pageInfo.kind || tab.url); + const pageType = identity.pageType || inferPageType(pageInfo, tab); + const visible = platform === 'xhs' && pageType === 'profile'; + elements.bloggerNotesPanel.classList.toggle('hidden', !visible); + if (!visible) { + elements.bloggerNotesControls.classList.add('hidden'); + elements.bloggerNotesPause.classList.add('hidden'); + elements.bloggerNotesResume.classList.add('hidden'); + elements.bloggerNotesCancel.classList.add('hidden'); + return; + } + + applyBloggerNotesSettings(); + const active = nextContext?.queue?.active || null; + const last = nextContext?.queue?.last || null; + const isRunning = active?.type === 'xhs:collect-blogger-notes'; + const paused = active?.paused === true; + const progress = active?.progress || null; + const disabled = !nextContext?.health?.success || Boolean(capturePendingAction) || isRunning; + + elements.bloggerNotesStart.disabled = disabled; + elements.bloggerNotesApiMode.disabled = isRunning; + elements.bloggerNotesLimit.disabled = isRunning; + elements.bloggerNotesIntervalMax.disabled = isRunning; + + elements.bloggerNotesControls.classList.toggle('hidden', !isRunning); + elements.bloggerNotesPause.classList.toggle('hidden', !isRunning || paused); + elements.bloggerNotesResume.classList.toggle('hidden', !isRunning || !paused); + elements.bloggerNotesCancel.classList.toggle('hidden', !isRunning); + + if (isRunning && progress) { + renderBloggerNotesProgress({ + label: paused ? '任务已暂停' : (progress.message || '正在采集博主笔记'), + status: paused ? 'idle' : 'pending', + current: Number(progress.current || 0), + total: Number(progress.total || 0), + meta: `已完成 ${Number(progress.current || 0)} / ${Number(progress.total || 0)}${paused ? ' · 点击继续恢复' : ''}`, + }); + } else if (!nextContext?.health?.success) { + renderBloggerNotesProgress({ + label: '桌面端未连接', + meta: '请先打开 RedBox 桌面端', + status: 'error', + }); + } else if (last?.type === 'xhs:collect-blogger-notes' && last?.status === 'cancelled') { + renderBloggerNotesProgress({ + label: '采集博主笔记已取消', + meta: `已保存 ${Number(last?.savedCount || 0)} 条`, + status: 'error', + current: Number(last?.savedCount || 0), + total: Math.max(Number(last?.progress?.total || 0), Number(last?.savedCount || 0)), + }); + } else { + renderBloggerNotesProgress({ + label: '准备开始采集', + meta: '设置模式、数量和间隔后即可开始', + status: 'idle', + }); + } +} + function getCaptureActionConfig(nextContext) { const tab = nextContext?.tab || {}; const pageInfo = nextContext?.pageInfo || {}; @@ -292,7 +563,6 @@ function getCaptureActionConfig(nextContext) { subtitle: '小红书博主页', actions: [ { label: '保存博主', action: 'blogger', primary: true, title: '保存当前博主资料到 RedBox' }, - { label: '采集博主笔记', action: 'bloggerNotes', title: '采集当前博主主页笔记' }, ], }; } @@ -421,6 +691,10 @@ async function refreshTaskQueue(showErrors = false) { const response = await sendMessage({ type: 'xhs:get-task-queue' }); renderTaskQueue(response.queue || {}); renderTaskLogs(response.queue?.logs || []); + renderBloggerNotesPanel({ + ...context, + queue: response.queue || {}, + }); } catch (error) { if (showErrors) { renderTaskQueue({ @@ -441,7 +715,13 @@ function renderTaskQueue(queue) { elements.taskQueueBadge.textContent = queued.length > 0 ? `执行中 · 排队 ${queued.length}` : '执行中'; elements.taskQueueBadge.className = 'task-badge running'; elements.taskCurrent.textContent = active.title || '小红书采集任务'; + const progressText = active?.progress?.total + ? `进度 ${Number(active.progress.current || 0)}/${Number(active.progress.total || 0)}` + : ''; elements.taskQueueMeta.textContent = [ + active?.paused ? '已暂停' : '', + progressText, + active?.progress?.message || '', active.startedAt ? `开始 ${formatTime(active.startedAt)}` : '', queued.length > 0 ? `后续 ${queued.map((item) => item.title || '任务').slice(0, 2).join('、')}${queued.length > 2 ? '...' : ''}` : '队列无等待任务', ].filter(Boolean).join(' · '); diff --git a/Plugin/vendor/md5.min.js b/Plugin/vendor/md5.min.js new file mode 100644 index 0000000..76bcc0c --- /dev/null +++ b/Plugin/vendor/md5.min.js @@ -0,0 +1,10 @@ +/** + * [js-md5]{@link https://github.com/emn178/js-md5} + * + * @namespace md5 + * @version 0.8.0 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2014-2023 + * @license MIT + */ +!function(){"use strict";function t(t){if(t)b[0]=b[16]=b[1]=b[2]=b[3]=b[4]=b[5]=b[6]=b[7]=b[8]=b[9]=b[10]=b[11]=b[12]=b[13]=b[14]=b[15]=0,this.blocks=b,this.buffer8=a;else if(u){var r=new ArrayBuffer(68);this.buffer8=new Uint8Array(r),this.blocks=new Uint32Array(r)}else this.blocks=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];this.h0=this.h1=this.h2=this.h3=this.start=this.bytes=this.hBytes=0,this.finalized=this.hashed=!1,this.first=!0}function r(r,e){var i,s=_(r);if(r=s[0],s[1]){var h,n=[],a=r.length,o=0;for(i=0;i>>6,n[o++]=128|63&h):h<55296||h>=57344?(n[o++]=224|h>>>12,n[o++]=128|h>>>6&63,n[o++]=128|63&h):(h=65536+((1023&h)<<10|1023&r.charCodeAt(++i)),n[o++]=240|h>>>18,n[o++]=128|h>>>12&63,n[o++]=128|h>>>6&63,n[o++]=128|63&h);r=n}r.length>64&&(r=new t(!0).update(r).array());var f=[],u=[];for(i=0;i<64;++i){var c=r[i]||0;f[i]=92^c,u[i]=54^c}t.call(this,e),this.update(u),this.oKeyPad=f,this.inner=!0,this.sharedMemory=e}var e="input is invalid type",i="object"==typeof window,s=i?window:{};s.JS_MD5_NO_WINDOW&&(i=!1);var h=!i&&"object"==typeof self,n=!s.JS_MD5_NO_NODE_JS&&"object"==typeof process&&process.versions&&process.versions.node;n?s=global:h&&(s=self);var a,o=!s.JS_MD5_NO_COMMON_JS&&"object"==typeof module&&module.exports,f="function"==typeof define&&define.amd,u=!s.JS_MD5_NO_ARRAY_BUFFER&&"undefined"!=typeof ArrayBuffer,c="0123456789abcdef".split(""),y=[128,32768,8388608,-2147483648],p=[0,8,16,24],d=["hex","array","digest","buffer","arrayBuffer","base64"],l="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split(""),b=[];if(u){var v=new ArrayBuffer(68);a=new Uint8Array(v),b=new Uint32Array(v)}var w=Array.isArray;!s.JS_MD5_NO_NODE_JS&&w||(w=function(t){return"[object Array]"===Object.prototype.toString.call(t)});var A=ArrayBuffer.isView;!u||!s.JS_MD5_NO_ARRAY_BUFFER_IS_VIEW&&A||(A=function(t){return"object"==typeof t&&t.buffer&&t.buffer.constructor===ArrayBuffer});var _=function(t){var r=typeof t;if("string"===r)return[t,!0];if("object"!==r||null===t)throw new Error(e);if(u&&t.constructor===ArrayBuffer)return[new Uint8Array(t),!1];if(!w(t)&&!A(t))throw new Error(e);return[t,!1]},B=function(r){return function(e){return new t(!0).update(e)[r]()}},g=function(t){var r,i=require("crypto"),h=require("buffer").Buffer;r=h.from&&!s.JS_MD5_NO_BUFFER_FROM?h.from:function(t){return new h(t)};return function(s){if("string"==typeof s)return i.createHash("md5").update(s,"utf8").digest("hex");if(null===s||void 0===s)throw new Error(e);return s.constructor===ArrayBuffer&&(s=new Uint8Array(s)),w(s)||A(s)||s.constructor===h?i.createHash("md5").update(r(s)).digest("hex"):t(s)}},m=function(t){return function(e,i){return new r(e,!0).update(i)[t]()}};t.prototype.update=function(t){if(this.finalized)throw new Error("finalize already called");var r=_(t);t=r[0];for(var e,i,s=r[1],h=0,n=t.length,a=this.blocks,o=this.buffer8;h>>6,o[i++]=128|63&e):e<55296||e>=57344?(o[i++]=224|e>>>12,o[i++]=128|e>>>6&63,o[i++]=128|63&e):(e=65536+((1023&e)<<10|1023&t.charCodeAt(++h)),o[i++]=240|e>>>18,o[i++]=128|e>>>12&63,o[i++]=128|e>>>6&63,o[i++]=128|63&e);else for(i=this.start;h>>2]|=e<>>2]|=(192|e>>>6)<>>2]|=(128|63&e)<=57344?(a[i>>>2]|=(224|e>>>12)<>>2]|=(128|e>>>6&63)<>>2]|=(128|63&e)<>>2]|=(240|e>>>18)<>>2]|=(128|e>>>12&63)<>>2]|=(128|e>>>6&63)<>>2]|=(128|63&e)<>>2]|=t[h]<=64?(this.start=i-64,this.hash(),this.hashed=!0):this.start=i}return this.bytes>4294967295&&(this.hBytes+=this.bytes/4294967296<<0,this.bytes=this.bytes%4294967296),this},t.prototype.finalize=function(){if(!this.finalized){this.finalized=!0;var t=this.blocks,r=this.lastByteIndex;t[r>>>2]|=y[3&r],r>=56&&(this.hashed||this.hash(),t[0]=t[16],t[16]=t[1]=t[2]=t[3]=t[4]=t[5]=t[6]=t[7]=t[8]=t[9]=t[10]=t[11]=t[12]=t[13]=t[14]=t[15]=0),t[14]=this.bytes<<3,t[15]=this.hBytes<<3|this.bytes>>>29,this.hash()}},t.prototype.hash=function(){var t,r,e,i,s,h,n=this.blocks;this.first?r=((r=((t=((t=n[0]-680876937)<<7|t>>>25)-271733879<<0)^(e=((e=(-271733879^(i=((i=(-1732584194^2004318071&t)+n[1]-117830708)<<12|i>>>20)+t<<0)&(-271733879^t))+n[2]-1126478375)<<17|e>>>15)+i<<0)&(i^t))+n[3]-1316259209)<<22|r>>>10)+e<<0:(t=this.h0,r=this.h1,e=this.h2,r=((r+=((t=((t+=((i=this.h3)^r&(e^i))+n[0]-680876936)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[1]-389564586)<<12|i>>>20)+t<<0)&(t^r))+n[2]+606105819)<<17|e>>>15)+i<<0)&(i^t))+n[3]-1044525330)<<22|r>>>10)+e<<0),r=((r+=((t=((t+=(i^r&(e^i))+n[4]-176418897)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[5]+1200080426)<<12|i>>>20)+t<<0)&(t^r))+n[6]-1473231341)<<17|e>>>15)+i<<0)&(i^t))+n[7]-45705983)<<22|r>>>10)+e<<0,r=((r+=((t=((t+=(i^r&(e^i))+n[8]+1770035416)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[9]-1958414417)<<12|i>>>20)+t<<0)&(t^r))+n[10]-42063)<<17|e>>>15)+i<<0)&(i^t))+n[11]-1990404162)<<22|r>>>10)+e<<0,r=((r+=((t=((t+=(i^r&(e^i))+n[12]+1804603682)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[13]-40341101)<<12|i>>>20)+t<<0)&(t^r))+n[14]-1502002290)<<17|e>>>15)+i<<0)&(i^t))+n[15]+1236535329)<<22|r>>>10)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[1]-165796510)<<5|t>>>27)+r<<0)^r))+n[6]-1069501632)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[11]+643717713)<<14|e>>>18)+i<<0)^i))+n[0]-373897302)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[5]-701558691)<<5|t>>>27)+r<<0)^r))+n[10]+38016083)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[15]-660478335)<<14|e>>>18)+i<<0)^i))+n[4]-405537848)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[9]+568446438)<<5|t>>>27)+r<<0)^r))+n[14]-1019803690)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[3]-187363961)<<14|e>>>18)+i<<0)^i))+n[8]+1163531501)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[13]-1444681467)<<5|t>>>27)+r<<0)^r))+n[2]-51403784)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[7]+1735328473)<<14|e>>>18)+i<<0)^i))+n[12]-1926607734)<<20|r>>>12)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[5]-378558)<<4|t>>>28)+r<<0))+n[8]-2022574463)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[11]+1839030562)<<16|e>>>16)+i<<0))+n[14]-35309556)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[1]-1530992060)<<4|t>>>28)+r<<0))+n[4]+1272893353)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[7]-155497632)<<16|e>>>16)+i<<0))+n[10]-1094730640)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[13]+681279174)<<4|t>>>28)+r<<0))+n[0]-358537222)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[3]-722521979)<<16|e>>>16)+i<<0))+n[6]+76029189)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[9]-640364487)<<4|t>>>28)+r<<0))+n[12]-421815835)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[15]+530742520)<<16|e>>>16)+i<<0))+n[2]-995338651)<<23|r>>>9)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[0]-198630844)<<6|t>>>26)+r<<0)|~e))+n[7]+1126891415)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[14]-1416354905)<<15|e>>>17)+i<<0)|~t))+n[5]-57434055)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[12]+1700485571)<<6|t>>>26)+r<<0)|~e))+n[3]-1894986606)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[10]-1051523)<<15|e>>>17)+i<<0)|~t))+n[1]-2054922799)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[8]+1873313359)<<6|t>>>26)+r<<0)|~e))+n[15]-30611744)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[6]-1560198380)<<15|e>>>17)+i<<0)|~t))+n[13]+1309151649)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[4]-145523070)<<6|t>>>26)+r<<0)|~e))+n[11]-1120210379)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[2]+718787259)<<15|e>>>17)+i<<0)|~t))+n[9]-343485551)<<21|r>>>11)+e<<0,this.first?(this.h0=t+1732584193<<0,this.h1=r-271733879<<0,this.h2=e-1732584194<<0,this.h3=i+271733878<<0,this.first=!1):(this.h0=this.h0+t<<0,this.h1=this.h1+r<<0,this.h2=this.h2+e<<0,this.h3=this.h3+i<<0)},t.prototype.hex=function(){this.finalize();var t=this.h0,r=this.h1,e=this.h2,i=this.h3;return c[t>>>4&15]+c[15&t]+c[t>>>12&15]+c[t>>>8&15]+c[t>>>20&15]+c[t>>>16&15]+c[t>>>28&15]+c[t>>>24&15]+c[r>>>4&15]+c[15&r]+c[r>>>12&15]+c[r>>>8&15]+c[r>>>20&15]+c[r>>>16&15]+c[r>>>28&15]+c[r>>>24&15]+c[e>>>4&15]+c[15&e]+c[e>>>12&15]+c[e>>>8&15]+c[e>>>20&15]+c[e>>>16&15]+c[e>>>28&15]+c[e>>>24&15]+c[i>>>4&15]+c[15&i]+c[i>>>12&15]+c[i>>>8&15]+c[i>>>20&15]+c[i>>>16&15]+c[i>>>28&15]+c[i>>>24&15]},t.prototype.toString=t.prototype.hex,t.prototype.digest=function(){this.finalize();var t=this.h0,r=this.h1,e=this.h2,i=this.h3;return[255&t,t>>>8&255,t>>>16&255,t>>>24&255,255&r,r>>>8&255,r>>>16&255,r>>>24&255,255&e,e>>>8&255,e>>>16&255,e>>>24&255,255&i,i>>>8&255,i>>>16&255,i>>>24&255]},t.prototype.array=t.prototype.digest,t.prototype.arrayBuffer=function(){this.finalize();var t=new ArrayBuffer(16),r=new Uint32Array(t);return r[0]=this.h0,r[1]=this.h1,r[2]=this.h2,r[3]=this.h3,t},t.prototype.buffer=t.prototype.arrayBuffer,t.prototype.base64=function(){for(var t,r,e,i="",s=this.array(),h=0;h<15;)t=s[h++],r=s[h++],e=s[h++],i+=l[t>>>2]+l[63&(t<<4|r>>>4)]+l[63&(r<<2|e>>>6)]+l[63&e];return t=s[h],i+=l[t>>>2]+l[t<<4&63]+"=="},(r.prototype=new t).finalize=function(){if(t.prototype.finalize.call(this),this.inner){this.inner=!1;var r=this.array();t.call(this,this.sharedMemory),this.update(this.oKeyPad),this.update(r),t.prototype.finalize.call(this)}};var O=function(){var r=B("hex");n&&(r=g(r)),r.create=function(){return new t},r.update=function(t){return r.create().update(t)};for(var e=0;e = { id: noteId, type: input.kind || 'webpage', + captureKind: input.kind || '', title: input.title || existingMeta?.title || '未命名内容', author: input.author || existingMeta?.author || '未知', authorProfileUrl: input.authorProfileUrl || existingMeta?.authorProfileUrl || '', diff --git a/desktop/src/pages/Knowledge.tsx b/desktop/src/pages/Knowledge.tsx index 011060d..572fa94 100644 --- a/desktop/src/pages/Knowledge.tsx +++ b/desktop/src/pages/Knowledge.tsx @@ -53,7 +53,7 @@ interface YouTubeVideo { folderPath?: string; } -type KnowledgeTypeFilter = 'all' | 'xhs-image' | 'xhs-video' | 'douyin-video' | 'link-article' | 'wechat-article' | 'youtube' | 'docs'; +type KnowledgeTypeFilter = 'all' | 'xhs-image' | 'xhs-video' | 'douyin-video' | 'link-article' | 'wechat-article' | 'youtube' | 'docs' | 'all-image' | 'all-video'; interface DocumentKnowledgeSource { id: string; @@ -713,10 +713,11 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = void loadAllKnowledge(); }, [loadAllKnowledge]); + // 搜索输入防抖:避免打字过程中频繁触发搜索 useEffect(() => { const timeout = window.setTimeout(() => { void loadAllKnowledge(); - }, 180); + }, 500); return () => window.clearTimeout(timeout); }, [searchQuery, selectedTypeFilter, loadAllKnowledge]); @@ -843,7 +844,7 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = : 'link-article' : note.captureKind === 'douyin-video' ? 'douyin-video' - : (note.captureKind === 'xhs-video' || note.video) + : (note.captureKind === 'xhs-video' || note.video || note.hasVideo) ? 'xhs-video' : 'xhs-image'; @@ -909,25 +910,39 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = } counts[item.kind] += 1; }); - return [ - { key: 'all' as const, label: '全部', count: Number(kindCounts['redbook-note'] || 0) + counts.youtube + counts.docs }, - { key: 'xhs-image' as const, label: '小红书图文', count: counts['xhs-image'] }, - { key: 'xhs-video' as const, label: '小红书视频', count: counts['xhs-video'] }, - { key: 'douyin-video' as const, label: '抖音视频', count: counts['douyin-video'] }, - { key: 'link-article' as const, label: '链接文章', count: counts['link-article'] }, - ...(SHOW_WECHAT_KNOWLEDGE_ACTIONS ? [{ key: 'wechat-article' as const, label: '公众号文章', count: counts['wechat-article'] }] : []), - { key: 'youtube' as const, label: 'YouTube', count: counts.youtube }, - { key: 'docs' as const, label: '文档', count: counts.docs }, + const platformFilters = [ + { key: 'all' as const, label: '全部', count: knowledgeItems.length + youtubeVideos.length + documentSources.length, isAggregate: false }, + { key: 'xhs-image' as const, label: '小红书图文', count: counts['xhs-image'], isAggregate: false }, + { key: 'xhs-video' as const, label: '小红书视频', count: counts['xhs-video'], isAggregate: false }, + { key: 'douyin-video' as const, label: '抖音视频', count: counts['douyin-video'], isAggregate: false }, + { key: 'link-article' as const, label: '链接文章', count: counts['link-article'], isAggregate: false }, + ...(SHOW_WECHAT_KNOWLEDGE_ACTIONS ? [{ key: 'wechat-article' as const, label: '公众号文章', count: counts['wechat-article'], isAggregate: false }] : []), + { key: 'youtube' as const, label: 'YouTube', count: counts.youtube, isAggregate: false }, + { key: 'docs' as const, label: '文档', count: counts.docs, isAggregate: false }, ].filter((item) => item.key === 'all' || item.count > 0); + // 聚合快捷筛选器(无计数,始终显示) + const aggFilters = [ + { key: 'all-image' as const, label: '仅图文', isAggregate: true }, + { key: 'all-video' as const, label: '仅视频', isAggregate: true }, + ]; + return [...platformFilters, ...aggFilters]; }, [kindCounts, knowledgeItems]); const youtubeSummaryPendingCount = useMemo(() => { return youtubeVideos.filter((video) => video.hasSubtitle && !String(video.summary || '').trim()).length; }, [youtubeVideos]); + // all-image: 图文笔记(跨平台纯图片/文字内容) + // all-video: 视频笔记(跨平台视频内容) const filteredKnowledgeItems = useMemo(() => { + const IMAGE_KINDS = new Set(['xhs-image', 'link-article', 'wechat-article']); + const VIDEO_KINDS = new Set(['xhs-video', 'douyin-video', 'youtube']); const filtered = knowledgeItems.filter((item) => { - if (selectedTypeFilter !== 'all' && item.kind !== selectedTypeFilter) { + if (selectedTypeFilter === 'all-image') { + if (!IMAGE_KINDS.has(item.kind)) return false; + } else if (selectedTypeFilter === 'all-video') { + if (!VIDEO_KINDS.has(item.kind)) return false; + } else if (selectedTypeFilter !== 'all' && item.kind !== selectedTypeFilter) { return false; } if (selectedTag && !item.tags.includes(selectedTag)) { @@ -1612,21 +1627,43 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = key={item.key} onClick={() => setSelectedTypeFilter(item.key)} className={clsx( - 'shrink-0 px-3.5 py-1.5 text-[12px] font-bold rounded-xl border transition-all flex items-center gap-2 active:scale-95', + item.isAggregate + ? 'shrink-0 px-3 py-1 text-[12px] rounded-lg border transition-all flex items-center gap-1.5 active:scale-95 font-medium' + : 'shrink-0 px-3.5 py-1.5 text-[12px] font-bold rounded-xl border transition-all flex items-center gap-2 active:scale-95', selectedTypeFilter === item.key - ? 'border-transparent bg-accent-primary text-white shadow-lg shadow-accent-primary/20' - : 'border-border/70 bg-surface-secondary/70 text-text-secondary hover:bg-surface-tertiary/70 hover:text-text-primary' + ? item.isAggregate + ? 'border-accent-primary/60 bg-accent-primary/10 text-accent-primary' + : 'border-transparent bg-accent-primary text-white shadow-lg shadow-accent-primary/20' + : item.isAggregate + ? 'border-border/50 bg-transparent text-text-tertiary hover:border-border hover:text-text-secondary' + : 'border-border/70 bg-surface-secondary/70 text-text-secondary hover:bg-surface-tertiary/70 hover:text-text-primary' )} > + {item.isAggregate && ( + + {selectedTypeFilter === item.key && ( + + + + )} + + )} {item.label} - - {item.count} - + {item.count !== undefined && ( + + {item.count} + + )} ))}
@@ -1729,8 +1766,8 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = className={clsx( 'shrink-0 px-3 py-1 text-[11px] font-bold rounded-lg transition-all border uppercase tracking-wider inline-flex items-center gap-1.5', !selectedTag - ? 'bg-black/[0.04] text-text-primary border-transparent shadow-sm' - : 'bg-transparent text-text-tertiary border-transparent hover:bg-black/[0.03] hover:text-text-secondary' + ? 'bg-surface-secondary text-text-primary border-transparent shadow-sm' + : 'bg-transparent text-text-tertiary border-transparent hover:bg-surface-secondary hover:text-text-secondary' )} > All Tags @@ -1738,8 +1775,8 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = className={clsx( 'inline-flex items-center justify-center rounded-md px-1.5 py-0.5 text-[9px] font-bold', !selectedTag - ? 'bg-black/5 text-text-tertiary/80' - : 'bg-black/[0.04] text-text-tertiary/70' + ? 'bg-surface-primary text-text-tertiary/80' + : 'bg-surface-secondary text-text-tertiary/70' )} > {allTags.length} @@ -1761,7 +1798,7 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = 'shrink-0 px-3 py-1 text-[11px] rounded-lg transition-all flex items-center gap-1.5 border font-bold', selectedTag === tag ? 'bg-accent-primary text-white border-transparent shadow-md shadow-accent-primary/20' - : 'bg-black/[0.02] text-text-tertiary border-transparent hover:bg-black/[0.04] hover:text-text-primary' + : 'bg-surface-secondary text-text-tertiary border-transparent hover:bg-surface-tertiary hover:text-text-primary' )} > # @@ -1771,7 +1808,7 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = 'text-[9px] py-0.5 px-1.5 rounded-md font-bold', selectedTag === tag ? 'bg-white/20 text-white' - : 'bg-black/5 text-text-tertiary/60' + : 'bg-surface-primary text-text-tertiary/60' )} > {count} @@ -1782,8 +1819,8 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded = {!selectedTag && isAllTagsDrawerOpen && hasHiddenTags && (
-
-
+
+
全部标签
@@ -1792,7 +1829,7 @@ export function Knowledge({ onNavigateToChat, onNavigateToRedClaw, isEmbedded =