feat: support douban sync (#223)

This commit is contained in:
Neo
2026-04-04 01:30:31 +08:00
committed by GitHub
parent 567c5f35d5
commit 7225b2a7d6
8 changed files with 369 additions and 4 deletions
+162
View File
@@ -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({
+1
View File
@@ -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 全局对象
+1
View File
@@ -214,3 +214,4 @@ async function handleDetectXiaohongshu() {
return { loggedIn: false, error: e.message }
}
}
+1 -4
View File
@@ -33,10 +33,7 @@ export default defineConfig({
plugins: [
viteStaticCopy({
targets: [
{
src: 'manifest.json',
dest: '.',
},
// manifest.json 由 scripts/cli.ts 生成
{
src: 'icons',
dest: '.',
+13
View File
@@ -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 }
+3
View File
@@ -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'
}
+2
View File
@@ -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) {
+186
View File
@@ -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]+)的账号</)
if (accountMatch?.[1]) {
username = accountMatch[1].trim()
}
}
if (!username || !uid) {
const profileLinkMatch = html.match(/https?:\/\/www\.douban\.com\/people\/([^/"?#]+)\/?/)
if (profileLinkMatch?.[1]) {
uid = profileLinkMatch[1]
}
}
if (!avatar) {
const avatarMatch = html.match(/https?:\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i)
|| html.match(/\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i)
|| html.match(/\/icon\/up\d+-\d+\.jpg/i)
if (avatarMatch?.[1]) {
avatar = avatarMatch[1]
} else if (avatarMatch?.[0]) {
avatar = avatarMatch[0]
}
if (avatar && avatar.startsWith('//')) {
avatar = `https:${avatar}`
} else if (avatar && avatar.startsWith('/icon/')) {
avatar = `https://img3.doubanio.com${avatar}`
}
}
if (username || avatar || uid) {
console.log('[COSE] douban 从 /mine/ HTML 获取用户信息:', username || uid)
}
}
} catch (e) {
console.log('[COSE] douban /mine/ 解析失败:', e.message)
}
// 3. Fallback: derive uid from dbcl2 cookie as username placeholder
if (!username && !uid && dbcl2Cookie.value) {
const uidFromCookie = dbcl2Cookie.value.match(/"?([^:"]+):/)
if (uidFromCookie?.[1]) {
uid = uidFromCookie[1]
}
}
if (!username && uid) {
username = uid
}
// 5. If avatar still missing but uid exists, fetch profile page and extract avatar
if (!avatar && uid) {
try {
const doubanCookies = await chrome.cookies.getAll({ domain: '.douban.com' })
const cookieHeader = doubanCookies.map(c => `${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 }
}
}