diff --git a/apps/extension/src/background.js b/apps/extension/src/background.js index 74704b1..e5622d1 100644 --- a/apps/extension/src/background.js +++ b/apps/extension/src/background.js @@ -3026,6 +3026,168 @@ async function syncToPlatform(platformId, content) { return { success: true, message: '已同步到电子发烧友', tabId: tab.id } } + // 豆瓣:向首页分享框注入内容 + if (platformId === 'douban') { + // 使用纯文本内容(豆瓣分享框不支持富文本) + const textContent = content.markdown || content.body || '' + console.log('[COSE] 豆瓣文本内容长度:', textContent?.length || 0) + + // 等待页面加载 + await new Promise(resolve => setTimeout(resolve, 2000)) + + // 填充分享框 + const fillResult = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: async (title, text) => { + try { + console.log('[COSE] 豆瓣开始填充内容...') + + if (!text) { + return { success: false, error: 'Empty content' } + } + + const fullText = title ? `${title}\n\n${text}` : text + + // 豆瓣当前输入框:Lexical contenteditable + const editable = document.querySelector('div.DRE-inputor.DRE-root[contenteditable="true"]') + || document.querySelector('[contenteditable="true"][role="textbox"]') + + if (editable) { + editable.focus() + + // 优先使用 Lexical 编辑器 API(豆瓣当前实现) + const lexicalEditor = editable.__lexicalEditor + if (lexicalEditor?.parseEditorState && lexicalEditor?.setEditorState) { + try { + const lines = fullText.split('\n') + const makeParagraph = (lineText) => ({ + children: lineText + ? [{ detail: 0, format: 0, mode: 'normal', style: '', text: lineText, type: 'text', version: 1 }] + : [], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }) + + const nextState = { + root: { + children: lines.map(makeParagraph), + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + } + + const parsedState = lexicalEditor.parseEditorState(JSON.stringify(nextState)) + lexicalEditor.setEditorState(parsedState) + lexicalEditor.focus() + + const lexicalLength = (editable.textContent || '').trim().length + if (lexicalLength > 0) { + console.log('[COSE] 豆瓣 lexical API 内容已填充,长度:', lexicalLength) + return { success: true, length: lexicalLength, mode: 'lexical-api' } + } + } catch (e) { + console.log('[COSE] 豆瓣 lexical API 填充失败,回退 execCommand:', e.message) + } + } + + // Lexical 编辑器对 direct textContent 赋值不稳定,优先使用 execCommand 输入 + try { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(editable) + selection?.removeAllRanges() + selection?.addRange(range) + } catch (_) {} + + try { + document.execCommand('selectAll', false) + } catch (_) {} + try { + document.execCommand('delete', false) + } catch (_) {} + + let inserted = false + try { + inserted = document.execCommand('insertText', false, fullText) + } catch (_) { + inserted = false + } + + if (!inserted) { + // 回退:直接赋值并触发输入事件 + editable.textContent = fullText + editable.dispatchEvent(new InputEvent('input', { + bubbles: true, + inputType: 'insertText', + data: fullText, + })) + } + + editable.dispatchEvent(new Event('change', { bubbles: true })) + + const actualLength = (editable.textContent || '').trim().length + if (actualLength === 0) { + return { success: false, error: 'Editor accepted no text' } + } + + console.log('[COSE] 豆瓣 contenteditable 内容已填充,长度:', actualLength) + return { success: true, length: actualLength, mode: 'contenteditable' } + } + + // 兼容旧版 textarea 结构 + const textarea = document.querySelector('textarea[placeholder*="此刻你想要分享"]') + || document.querySelector('textarea[placeholder*="分享"]') + || document.querySelector('textarea') + + if (textarea) { + textarea.focus() + const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set + if (nativeSetter) { + nativeSetter.call(textarea, fullText) + } else { + textarea.value = fullText + } + textarea.dispatchEvent(new Event('input', { bubbles: true })) + textarea.dispatchEvent(new Event('change', { bubbles: true })) + console.log('[COSE] 豆瓣 textarea 内容已填充,长度:', fullText.length) + return { success: true, length: fullText.length, mode: 'textarea' } + } + + return { success: false, error: 'Editor not found' } + } catch (e) { + console.error('[COSE] 豆瓣同步失败:', e) + return { success: false, error: e.message } + } + }, + args: [content.title, textContent], + world: 'MAIN', + }) + + const doubanResult = fillResult[0]?.result + console.log('[COSE] 豆瓣填充结果:', doubanResult) + + if (!doubanResult?.success) { + return { + success: false, + message: doubanResult?.error || '豆瓣内容填充失败', + tabId: tab.id, + } + } + + // 等待内容注入完成 + await new Promise(resolve => setTimeout(resolve, 1000)) + + return { success: true, message: '已同步到豆瓣,请手动点击发布', tabId: tab.id } + } + // 其他平台使用 scripting API 直接注入填充脚本 // 使用 MAIN world 才能访问页面的 CodeMirror 实例 await chrome.scripting.executeScript({ diff --git a/apps/extension/src/inject.js b/apps/extension/src/inject.js index a3a6e69..1b18c7f 100644 --- a/apps/extension/src/inject.js +++ b/apps/extension/src/inject.js @@ -129,6 +129,7 @@ { id: 'douyin', name: 'Douyin', icon: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/yvahlyj_upfbvk_zlp/ljhwZthlaukjlkulzlp/pc_creator/favicon_v2_7145ff0.ico', title: '抖音文章', type: 'douyin', url: 'https://creator.douyin.com/creator-micro/content/post/article?default-tab=5&enter_from=publish_page&media_type=article&type=new' }, { id: 'xiaohongshu', name: 'Xiaohongshu', icon: 'https://www.xiaohongshu.com/favicon.ico', title: '小红书', type: 'xiaohongshu', url: 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=article' }, { id: 'elecfans', name: 'Elecfans', icon: 'https://www.elecfans.com/favicon.ico', title: '电子发烧友', type: 'elecfans', url: 'https://www.elecfans.com/d/article/md/' }, + { id: 'douban', name: 'Douban', icon: 'https://cdn.simpleicons.org/douban/07C160', title: '豆瓣', type: 'douban', url: 'https://www.douban.com/' }, ] // 暴露 $cose 全局对象 diff --git a/apps/extension/src/offscreen.js b/apps/extension/src/offscreen.js index 8b2c272..ce95753 100644 --- a/apps/extension/src/offscreen.js +++ b/apps/extension/src/offscreen.js @@ -214,3 +214,4 @@ async function handleDetectXiaohongshu() { return { loggedIn: false, error: e.message } } } + diff --git a/apps/extension/vite.config.js b/apps/extension/vite.config.js index 03fee40..dc4e2a5 100644 --- a/apps/extension/vite.config.js +++ b/apps/extension/vite.config.js @@ -33,10 +33,7 @@ export default defineConfig({ plugins: [ viteStaticCopy({ targets: [ - { - src: 'manifest.json', - dest: '.', - }, + // manifest.json 由 scripts/cli.ts 生成 { src: 'icons', dest: '.', diff --git a/packages/core/src/platforms/douban.js b/packages/core/src/platforms/douban.js new file mode 100644 index 0000000..03f87a2 --- /dev/null +++ b/packages/core/src/platforms/douban.js @@ -0,0 +1,13 @@ +// 豆瓣平台配置 +const DoubanPlatform = { + id: 'douban', + name: 'Douban', + icon: 'https://cdn.simpleicons.org/douban/07C160', + url: 'https://www.douban.com', + publishUrl: 'https://www.douban.com/', + title: '豆瓣', + type: 'douban', +} + +// 导出 +export { DoubanPlatform } diff --git a/packages/core/src/platforms/index.js b/packages/core/src/platforms/index.js index 7ba23e8..f104479 100644 --- a/packages/core/src/platforms/index.js +++ b/packages/core/src/platforms/index.js @@ -33,6 +33,7 @@ import { VolcenginePlatform } from './volcengine.js' import { DouyinPlatform } from './douyin.js' import { XiaohongshuPlatform } from './xiaohongshu.js' import { ElecfansPlatform } from './elecfans.js' +import { DoubanPlatform } from './douban.js' // 合并平台配置 const PLATFORMS = [ @@ -66,6 +67,7 @@ const PLATFORMS = [ DouyinPlatform, XiaohongshuPlatform, ElecfansPlatform, + DoubanPlatform, ] // 根据 hostname 获取平台填充函数 @@ -100,6 +102,7 @@ function getPlatformFiller(hostname) { if (hostname.includes('creator.douyin.com')) return 'douyin' if (hostname.includes('creator.xiaohongshu.com')) return 'xiaohongshu' if (hostname.includes('elecfans.com')) return 'elecfans' + if (hostname.includes('douban.com')) return 'douban' return 'generic' } diff --git a/packages/detection/src/detect.js b/packages/detection/src/detect.js index 310bd5f..0bc9ad3 100644 --- a/packages/detection/src/detect.js +++ b/packages/detection/src/detect.js @@ -25,6 +25,7 @@ import { detectModelScopeUser } from './platforms/modelscope.js' import { detectVolcengineUser } from './platforms/volcengine.js' import { detectCnblogsUser } from './platforms/cnblogs.js' import { detectWangyihaoUser } from './platforms/wangyihao.js' +import { detectDoubanUser } from './platforms/douban.js' // Platform-specific detectors map const PLATFORM_DETECTORS = { @@ -53,6 +54,7 @@ const PLATFORM_DETECTORS = { 'volcengine': detectVolcengineUser, 'cnblogs': detectCnblogsUser, 'wangyihao': detectWangyihaoUser, + 'douban': detectDoubanUser, } export async function detectUser(platformId) { diff --git a/packages/detection/src/platforms/douban.js b/packages/detection/src/platforms/douban.js new file mode 100644 index 0000000..4e9b16e --- /dev/null +++ b/packages/detection/src/platforms/douban.js @@ -0,0 +1,186 @@ +import { convertAvatarToBase64 } from '../utils.js' + +async function convertToBase64WithFallback(avatarUrl) { + if (!avatarUrl) return '' + + // Use shared utility only + try { + const converted = await convertAvatarToBase64(avatarUrl, 'https://www.douban.com/') + if (converted && converted.startsWith('data:')) { + return converted + } + } catch (e) { + console.log('[COSE] douban 通用头像转换失败:', e.message) + } + + // Fallback: manual fetch with cookies + try { + const doubanCookies = await chrome.cookies.getAll({ domain: '.douban.com' }) + const cookieHeader = doubanCookies.map(c => `${c.name}=${c.value}`).join('; ') + const imgResp = await fetch(avatarUrl, { + method: 'GET', + headers: { + 'Referer': 'https://www.douban.com/', + ...(cookieHeader ? { 'Cookie': cookieHeader } : {}) + }, + credentials: 'include' + }) + if (!imgResp.ok) { + return avatarUrl + } + const blob = await imgResp.blob() + const buffer = await blob.arrayBuffer() + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return `data:${blob.type || 'image/jpeg'};base64,${btoa(binary)}` + } catch (e) { + console.log('[COSE] douban 手动头像转换失败:', e.message) + return avatarUrl + } +} + +/** + * Douban platform detection logic + * Strategy: + * 1. Check dbcl2 cookie on douban.com as login indicator + * 2. Parse /mine/ HTML to get user info + * 3. Fallback: derive uid from dbcl2 cookie + * 4. If avatar missing but uid exists, fetch profile page + */ +export async function detectDoubanUser() { + try { + // 1. Check dbcl2 cookie as login indicator + const dbcl2Cookie = await chrome.cookies.get({ + url: 'https://www.douban.com', + name: 'dbcl2' + }) + + if (!dbcl2Cookie || !dbcl2Cookie.value) { + console.log('[COSE] douban 未找到登录 cookie,未登录') + return { loggedIn: false } + } + + // Logged in — now try to get user details + let username = '' + let avatar = '' + let uid = '' + + // 2. Parse /mine/ HTML to get user info + try { + const doubanCookies = await chrome.cookies.getAll({ domain: '.douban.com' }) + const cookieHeader = doubanCookies.map(c => `${c.name}=${c.value}`).join('; ') + + const response = await fetch('https://www.douban.com/mine/', { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'text/html,application/xhtml+xml', + ...(cookieHeader ? { 'Cookie': cookieHeader } : {}) + } + }) + + if (response.ok) { + const html = await response.text() + + if (!username) { + const accountMatch = html.match(/>([^<\n]+)的账号 `${c.name}=${c.value}`).join('; ') + const profileResp = await fetch(`https://www.douban.com/people/${uid}/`, { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'text/html,application/xhtml+xml', + ...(cookieHeader ? { 'Cookie': cookieHeader } : {}) + } + }) + + if (profileResp.ok) { + const profileHtml = await profileResp.text() + const profileAvatar = profileHtml.match(/https?:\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i) + || profileHtml.match(/\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i) + if (profileAvatar?.[0]) { + avatar = profileAvatar[0] + console.log('[COSE] douban 从个人页补充头像成功') + } + } + } catch (e) { + console.log('[COSE] douban 从个人页补充头像失败:', e.message) + } + } + + if (avatar && avatar.startsWith('//')) { + avatar = `https:${avatar}` + } + + // Convert douban avatar to base64 if needed + if (avatar && avatar.startsWith('http')) { + try { + avatar = await convertToBase64WithFallback(avatar) + } catch (e) { + console.log('[COSE] douban 头像转换失败:', e.message) + } + } + + // Cookie exists means logged in; return best-effort user details + return { loggedIn: true, username: username || '', avatar: avatar || '' } + } catch (e) { + console.log('[COSE] douban 检测失败:', e.message) + return { loggedIn: false } + } +}