mirror of
https://github.com/doocs/cose.git
synced 2026-09-01 15:34:32 +08:00
feat: support douban sync (#223)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 全局对象
|
||||
|
||||
@@ -214,3 +214,4 @@ async function handleDetectXiaohongshu() {
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: 'manifest.json',
|
||||
dest: '.',
|
||||
},
|
||||
// manifest.json 由 scripts/cli.ts 生成
|
||||
{
|
||||
src: 'icons',
|
||||
dest: '.',
|
||||
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user