Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f55d2c809 | |||
| 1e48681c86 | |||
| fd192bf9ba |
@@ -12,9 +12,9 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
|
||||
|
||||
## Browser Provider
|
||||
|
||||
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前首个完整接入平台是掘金:扩展读取当前掘金 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充 Markdown 编辑器。
|
||||
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入掘金、简书、知乎和今日头条:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条会将 Markdown 转为安全的富文本粘贴,保留标题、段落、列表、链接和图片。
|
||||
|
||||
任务会复用已有编辑器标签页。标签页仍位于新建编辑器、已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。`/editor/drafts/:id` 只记为草稿已保存,只有 `/post/:id` 才记为已发布;无法确定时进入“发布结果待确认”,不会伪报成功。
|
||||
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id` 或 `www.toutiao.com/item/:id` 才记为已发布;知乎 `/write/:id` 和今日头条带 `draft_id` 的编辑器地址记为草稿,无法确定时进入“发布结果待确认”,不会伪报成功。
|
||||
|
||||
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
|
||||
|
||||
|
||||
@@ -709,7 +709,7 @@ async function openPlatformTask(platformId, content, expectedPlatformUid, existi
|
||||
if (inspect && Number.isInteger(existingTabId) && existingTabId > 0) {
|
||||
try {
|
||||
const existingTab = await chrome.tabs.get(existingTabId)
|
||||
const existingState = inspect(existingTab.url || '')
|
||||
const existingState = await inspect(existingTab, { chrome })
|
||||
if (canReturnExistingPlatformTaskState(existingState)) {
|
||||
await chrome.tabs.update(existingTabId, { active: true })
|
||||
return {
|
||||
@@ -735,8 +735,8 @@ async function openPlatformTask(platformId, content, expectedPlatformUid, existi
|
||||
await chrome.tabs.update(result.tabId, { active: true })
|
||||
}
|
||||
const tab = result.tabId ? await chrome.tabs.get(result.tabId) : null
|
||||
const state = inspect && tab?.url
|
||||
? inspect(tab.url)
|
||||
const state = inspect && tab
|
||||
? await inspect(tab, { chrome })
|
||||
: { status: 'filled' }
|
||||
return {
|
||||
...result,
|
||||
@@ -769,7 +769,7 @@ async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
|
||||
} catch {
|
||||
return { success: false, error: '平台编辑器标签页已经关闭,请重新打开并填充' }
|
||||
}
|
||||
const state = inspect(tab.url || '')
|
||||
const state = await inspect(tab, { chrome })
|
||||
return {
|
||||
success: state.status !== 'failed',
|
||||
...state,
|
||||
@@ -912,59 +912,6 @@ async function syncToPlatform(platformId, content, options = {}) {
|
||||
console.error('[COSE] InfoQ API 调用失败:', e)
|
||||
return { success: false, message: 'InfoQ API 调用失败: ' + e.message }
|
||||
}
|
||||
} else if (platformId === 'jianshu') {
|
||||
// 简书:需要先获取文集列表,然后创建新文章
|
||||
try {
|
||||
// 获取用户的文集列表
|
||||
const notebooksResp = await fetch('https://www.jianshu.com/author/notebooks', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
const notebooks = await notebooksResp.json()
|
||||
|
||||
if (!notebooks || notebooks.length === 0) {
|
||||
return { success: false, message: '简书未找到文集,请先创建一个文集' }
|
||||
}
|
||||
|
||||
// 使用第一个文集
|
||||
const notebookId = notebooks[0].id
|
||||
console.log('[COSE] 简书使用文集:', notebooks[0].name, 'ID:', notebookId)
|
||||
|
||||
// 创建新文章
|
||||
const createResp = await fetch('https://www.jianshu.com/author/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notebook_id: String(notebookId),
|
||||
title: content.title || '无标题',
|
||||
at_bottom: false,
|
||||
}),
|
||||
})
|
||||
const noteData = await createResp.json()
|
||||
|
||||
if (noteData && noteData.id) {
|
||||
const noteId = noteData.id
|
||||
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${noteId}`
|
||||
console.log('[COSE] 简书创建文章成功,ID:', noteId)
|
||||
|
||||
tab = await chrome.tabs.create({ url: targetUrl, active: false })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
await waitForTab(tab.id)
|
||||
} else {
|
||||
console.error('[COSE] 简书创建文章失败:', noteData)
|
||||
return { success: false, message: '简书创建文章失败,请确保已登录' }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[COSE] 简书 API 调用失败:', e)
|
||||
return { success: false, message: '简书 API 调用失败: ' + e.message }
|
||||
}
|
||||
} else if (platformId === 'xiaohongshu') {
|
||||
// 小红书:需要先点击"新的创作"按钮,等待编辑器加载后填充
|
||||
console.log('[COSE] 开始处理小红书同步...')
|
||||
@@ -3857,49 +3804,6 @@ function fillContentOnPage(content, platformId) {
|
||||
document.head.appendChild(script)
|
||||
script.remove()
|
||||
}
|
||||
// 简书
|
||||
else if (host.includes('jianshu.com')) {
|
||||
// 填充标题 - 简书使用 input._24i7u,需要使用 native setter
|
||||
const titleInput = await waitFor('input._24i7u, input[class*="title"]')
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
const inputSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
inputSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
console.log('[COSE] 简书标题填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 简书未找到标题输入框')
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 简书使用 textarea#arthur-editor 作为 Markdown 编辑器
|
||||
const editor =
|
||||
document.querySelector('#arthur-editor') || document.querySelector('textarea._3swFR')
|
||||
if (editor) {
|
||||
editor.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
textareaSetter.call(editor, contentToFill)
|
||||
editor.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' })
|
||||
)
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 简书内容填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 简书未找到编辑器')
|
||||
}
|
||||
}
|
||||
// 腾讯云开发者社区
|
||||
else if (host.includes('cloud.tencent.com')) {
|
||||
console.log('[COSE] TencentCloud 开始同步...')
|
||||
|
||||
@@ -6,14 +6,26 @@ import { LOGIN_CHECK_CONFIG } from '../../detection/index.js'
|
||||
import { CSDNPlatform, syncCSDNContent } from './csdn.js'
|
||||
import { inspectJuejinTaskState, JuejinPlatform, syncJuejinContent } from './juejin.js'
|
||||
import { WechatPlatform, syncWechatContent } from './wechat.js'
|
||||
import { ZhihuPlatform, syncZhihuContent } from './zhihu.js'
|
||||
import { ToutiaoPlatform, syncToutiaoContent } from './toutiao.js'
|
||||
import {
|
||||
inspectZhihuTask,
|
||||
ZhihuPlatform,
|
||||
syncZhihuContent,
|
||||
} from './zhihu.js'
|
||||
import {
|
||||
inspectToutiaoTask,
|
||||
ToutiaoPlatform,
|
||||
syncToutiaoContent,
|
||||
} from './toutiao.js'
|
||||
import { SegmentFaultPlatform } from './segmentfault.js'
|
||||
import { CnblogsPlatform } from './cnblogs.js'
|
||||
import { OSChinaPlatform } from './oschina.js'
|
||||
import { CTO51Platform } from './cto51.js'
|
||||
import { InfoQPlatform } from './infoq.js'
|
||||
import { JianshuPlatform } from './jianshu.js'
|
||||
import {
|
||||
inspectJianshuTask,
|
||||
JianshuPlatform,
|
||||
syncJianshuContent,
|
||||
} from './jianshu.js'
|
||||
import { BaijiahaoPlatform } from './baijiahao.js'
|
||||
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
|
||||
import { TencentCloudPlatform } from './tencentcloud.js'
|
||||
@@ -112,6 +124,7 @@ function getPlatformFiller(hostname) {
|
||||
const SYNC_HANDLERS = {
|
||||
csdn: syncCSDNContent,
|
||||
juejin: syncJuejinContent,
|
||||
jianshu: syncJianshuContent,
|
||||
wechat: syncWechatContent,
|
||||
zhihu: syncZhihuContent,
|
||||
toutiao: syncToutiaoContent,
|
||||
@@ -119,7 +132,10 @@ const SYNC_HANDLERS = {
|
||||
}
|
||||
|
||||
const INSPECT_HANDLERS = {
|
||||
juejin: inspectJuejinTaskState,
|
||||
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
|
||||
jianshu: inspectJianshuTask,
|
||||
zhihu: inspectZhihuTask,
|
||||
toutiao: inspectToutiaoTask,
|
||||
}
|
||||
|
||||
// 导出
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 简书平台配置
|
||||
const JianshuPlatform = {
|
||||
id: 'jianshu',
|
||||
@@ -9,51 +11,207 @@ const JianshuPlatform = {
|
||||
type: 'jianshu',
|
||||
}
|
||||
|
||||
// 简书内容填充函数
|
||||
async function fillJianshuContent(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
// 填充标题 - 简书使用 input._24i7u,需要使用 native setter
|
||||
const titleInput = await waitFor('input._24i7u, input[class*="title"]')
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
const inputSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
inputSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
console.log('[COSE] 简书标题填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 简书未找到标题输入框')
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 简书使用 textarea#arthur-editor 作为 Markdown 编辑器
|
||||
const editor =
|
||||
document.querySelector('#arthur-editor') || document.querySelector('textarea._3swFR')
|
||||
if (editor) {
|
||||
editor.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
textareaSetter.call(editor, contentToFill)
|
||||
editor.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' })
|
||||
)
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 简书内容填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 简书未找到编辑器')
|
||||
function parseJianshuPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://www.jianshu.com')
|
||||
if (url.protocol !== 'https:' || !['jianshu.com', 'www.jianshu.com'].includes(url.hostname))
|
||||
return null
|
||||
const published = url.pathname.match(/^\/p\/([^/]+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认简书文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { JianshuPlatform, fillJianshuContent }
|
||||
function inspectJianshuTaskState(rawUrl, publicUrl) {
|
||||
const published = parseJianshuPublishedUrl(publicUrl) || parseJianshuPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取简书标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || !['jianshu.com', 'www.jianshu.com'].includes(url.hostname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开简书,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
const draft = url.hash.match(/^#\/notebooks\/([^/]+)\/notes\/([^/?#]+)/)
|
||||
if (url.pathname === '/writer' && draft?.[2]) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draft[2],
|
||||
message: '已确认简书草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: url.pathname === '/writer'
|
||||
? '简书编辑器尚未进入本次任务文章,请重新打开并填充'
|
||||
: '当前简书页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectJianshuTask(tab, helpers) {
|
||||
const currentState = inspectJianshuTaskState(tab?.url || '')
|
||||
if (currentState.status !== 'draft_saved' || !tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const links = Array.from(document.querySelectorAll('a[href]'))
|
||||
const publicLink = links.find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const isPublicArticle = ['jianshu.com', 'www.jianshu.com'].includes(url.hostname)
|
||||
&& /^\/p\/[^/]+\/?$/.test(url.pathname)
|
||||
const isCurrentArticleAction = link.target === '_blank'
|
||||
|| /查看文章|阅读文章|已发布/.test(link.textContent || '')
|
||||
return isPublicArticle && isCurrentArticleAction
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return publicLink ? new URL(publicLink.getAttribute('href'), location.href).href : undefined
|
||||
},
|
||||
})
|
||||
return inspectJianshuTaskState(tab.url || '', result?.[0]?.result)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
function fillJianshuContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await window.waitFor('input._24i7u, input[class*="title"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
const inputSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set
|
||||
inputSetter?.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' }),
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const editor = document.querySelector('#arthur-editor')
|
||||
|| document.querySelector('textarea._3swFR')
|
||||
if (!editor) return { success: false, error: 'Editor not found' }
|
||||
|
||||
editor.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value',
|
||||
)?.set
|
||||
textareaSetter?.call(editor, contentToFill)
|
||||
editor.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' }),
|
||||
)
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function syncJianshuContent(tab, content, helpers) {
|
||||
const { chrome, waitForTab } = helpers
|
||||
|
||||
const notebooksResponse = await fetch('https://www.jianshu.com/author/notebooks', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!notebooksResponse.ok) {
|
||||
return { success: false, message: '简书文集读取失败,请确认当前 Chrome 已登录', tabId: tab.id }
|
||||
}
|
||||
|
||||
const notebooks = await notebooksResponse.json()
|
||||
const notebookId = Array.isArray(notebooks) ? notebooks[0]?.id : undefined
|
||||
if (!notebookId) {
|
||||
return { success: false, message: '简书未找到文集,请先创建一个文集', tabId: tab.id }
|
||||
}
|
||||
|
||||
const createResponse = await fetch('https://www.jianshu.com/author/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notebook_id: String(notebookId),
|
||||
title: content.title || '无标题',
|
||||
at_bottom: false,
|
||||
}),
|
||||
})
|
||||
if (!createResponse.ok) {
|
||||
return { success: false, message: '简书草稿创建失败,请确认登录状态和文集权限', tabId: tab.id }
|
||||
}
|
||||
|
||||
const note = await createResponse.json()
|
||||
if (!note?.id) {
|
||||
return { success: false, message: '简书草稿创建结果缺少文章 ID', tabId: tab.id }
|
||||
}
|
||||
|
||||
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${note.id}`
|
||||
const updatedTab = await chrome.tabs.update(tab.id, { url: targetUrl })
|
||||
await waitForTab(updatedTab.id)
|
||||
await injectUtils(chrome, updatedTab.id)
|
||||
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: updatedTab.id },
|
||||
func: fillJianshuContent,
|
||||
args: [content.title, content.markdown, content.body],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '简书内容填充失败',
|
||||
tabId: updatedTab.id,
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
return {
|
||||
success: true,
|
||||
message: '已同步到简书草稿',
|
||||
tabId: updatedTab.id,
|
||||
platformWorkId: String(note.id),
|
||||
url: targetUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
JianshuPlatform,
|
||||
fillJianshuContent,
|
||||
inspectJianshuTask,
|
||||
inspectJianshuTaskState,
|
||||
syncJianshuContent,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 今日头条平台配置
|
||||
const ToutiaoPlatform = {
|
||||
id: 'toutiao',
|
||||
name: 'Toutiao',
|
||||
@@ -9,134 +8,434 @@ const ToutiaoPlatform = {
|
||||
type: 'toutiao',
|
||||
}
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
function parseToutiaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
// 今日头条内容填充函数(在页面主世界中执行)
|
||||
function fillToutiaoContentInPage(title, body) {
|
||||
// 等待满足条件的元素出现
|
||||
function waitForElement(predicate, timeout = 10000) {
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://mp.toutiao.com')
|
||||
if (url.protocol !== 'https:' || !['toutiao.com', 'www.toutiao.com'].includes(url.hostname)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const published = url.pathname.match(/^\/(?:article|item|w)\/([^/?#]+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认今日头条文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getToutiaoDraftId(url) {
|
||||
for (const key of ['draft_id', 'draftId']) {
|
||||
const value = url.searchParams.get(key)
|
||||
if (value?.trim()) return value.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inspectToutiaoTaskState(rawUrl, publicUrl) {
|
||||
const published = parseToutiaoPublishedUrl(publicUrl) || parseToutiaoPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取今日头条标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'mp.toutiao.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开今日头条,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/profile_v4\/graphic\/publish\/?$/.test(url.pathname)) {
|
||||
const draftId = getToutiaoDraftId(url)
|
||||
if (draftId) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: '已确认今日头条草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/profile_v4\/graphic\/articles\/?$/.test(url.pathname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '已回到今日头条文章列表,请打开本次文章核对发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前今日头条页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function renderToutiaoInline(value) {
|
||||
const pattern = /!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g
|
||||
let html = ''
|
||||
let cursor = 0
|
||||
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
html += escapeHtml(value.slice(cursor, match.index))
|
||||
if (match[1]) {
|
||||
const url = safeUrl(match[2])
|
||||
html += url
|
||||
? `<img src="${escapeHtml(url)}" alt="${escapeHtml(match[1])}" />`
|
||||
: escapeHtml(match[0])
|
||||
} else if (match[3]) {
|
||||
const url = safeUrl(match[4])
|
||||
html += url
|
||||
? `<a href="${escapeHtml(url)}">${escapeHtml(match[3])}</a>`
|
||||
: escapeHtml(match[0])
|
||||
} else if (match[5]) {
|
||||
html += `<code>${escapeHtml(match[5])}</code>`
|
||||
} else if (match[6] || match[7]) {
|
||||
html += `<strong>${escapeHtml(match[6] || match[7])}</strong>`
|
||||
} else {
|
||||
html += `<em>${escapeHtml(match[8] || match[9])}</em>`
|
||||
}
|
||||
cursor = match.index + match[0].length
|
||||
}
|
||||
|
||||
return html + escapeHtml(value.slice(cursor))
|
||||
}
|
||||
|
||||
function renderToutiaoMarkdown(markdown) {
|
||||
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const blocks = []
|
||||
let paragraph = []
|
||||
let listType
|
||||
let listItems = []
|
||||
let codeLines
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length) {
|
||||
blocks.push(`<p>${paragraph.map(renderToutiaoInline).join('<br />')}</p>`)
|
||||
paragraph = []
|
||||
}
|
||||
}
|
||||
|
||||
const flushList = () => {
|
||||
if (!listType) return
|
||||
blocks.push(`<${listType}>${listItems.map(item => `<li>${renderToutiaoInline(item)}</li>`).join('')}</${listType}>`)
|
||||
listType = undefined
|
||||
listItems = []
|
||||
}
|
||||
|
||||
const flushBlocks = () => {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (codeLines) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
codeLines = undefined
|
||||
} else {
|
||||
codeLines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*```/.test(line)) {
|
||||
flushBlocks()
|
||||
codeLines = []
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushBlocks()
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
|
||||
if (heading) {
|
||||
flushBlocks()
|
||||
const level = heading[1].length
|
||||
blocks.push(`<h${level}>${renderToutiaoInline(heading[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const unordered = line.match(/^\s*[-*+]\s+(.+)$/)
|
||||
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/)
|
||||
if (unordered || ordered) {
|
||||
flushParagraph()
|
||||
const nextType = unordered ? 'ul' : 'ol'
|
||||
if (listType && listType !== nextType) flushList()
|
||||
listType = nextType
|
||||
listItems.push((unordered || ordered)[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const quote = line.match(/^\s*>\s?(.*)$/)
|
||||
if (quote) {
|
||||
flushBlocks()
|
||||
blocks.push(`<blockquote>${renderToutiaoInline(quote[1])}</blockquote>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
|
||||
flushBlocks()
|
||||
blocks.push('<hr />')
|
||||
continue
|
||||
}
|
||||
|
||||
if (listType) flushList()
|
||||
paragraph.push(line)
|
||||
}
|
||||
|
||||
if (codeLines) blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
flushBlocks()
|
||||
return blocks.join('')
|
||||
}
|
||||
|
||||
function renderToutiaoPlainText(markdown) {
|
||||
return String(markdown || '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[`*_#>~-]/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function fillToutiaoContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const el = predicate()
|
||||
if (el) return resolve(el)
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = predicate()
|
||||
if (el) {
|
||||
observer.disconnect()
|
||||
resolve(el)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(predicate())
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
async function fillContent() {
|
||||
// 填充标题 - 头条使用 textarea
|
||||
const titleInput = await waitForElement(() =>
|
||||
document.querySelector('textarea[placeholder*="标题"]')
|
||||
)
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 模拟用户输入
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
nativeSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
console.log('[COSE] 头条标题填充成功:', title)
|
||||
} else {
|
||||
console.log('[COSE] 头条未找到标题输入框')
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
input.focus()
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function selectEditorContents(editor) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function dispatchHtmlPaste(editor, html, plainText) {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
if (typeof DataTransfer !== 'undefined' && typeof ClipboardEvent !== 'undefined') {
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', plainText)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'.editor-title textarea',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到今日头条标题输入框' }
|
||||
if (title) setInputValue(titleInput, title)
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
if (!editor) return { success: false, error: '未找到今日头条正文编辑器' }
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const html = renderToutiaoMarkdown(contentToFill)
|
||||
const plainText = renderToutiaoPlainText(contentToFill)
|
||||
dispatchHtmlPaste(editor, html, plainText)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
const expectedTextLength = plainText.length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
let editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
let imageCount = editor.querySelectorAll('img').length
|
||||
|
||||
if (editorTextLength + imageCount * 12 < minimumLength && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, html)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
|
||||
await sleep(500)
|
||||
editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
imageCount = editor.querySelectorAll('img').length
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
if (editorTextLength + imageCount * 12 < minimumLength) {
|
||||
return {
|
||||
success: false,
|
||||
error: '今日头条未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
// 头条使用 ProseMirror 富文本编辑器
|
||||
const editor = await waitForElement(() => document.querySelector('.ProseMirror'))
|
||||
|
||||
if (editor && body) {
|
||||
editor.focus()
|
||||
|
||||
// 对于 ProseMirror,我们需要更智能的方式来填充内容
|
||||
// 清空现有内容
|
||||
editor.innerHTML = ''
|
||||
|
||||
// 将内容分割成段落
|
||||
const lines = body.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
// 使用 document.execCommand 插入内容(ProseMirror 兼容)
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
// 创建 HTML 内容
|
||||
const htmlContent = lines.map(line => `<p>${line}</p>`).join('')
|
||||
|
||||
// 使用 insertHTML 命令
|
||||
document.execCommand('insertHTML', false, htmlContent)
|
||||
|
||||
// 触发事件让 ProseMirror 同步
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
console.log('[COSE] 头条内容填充成功')
|
||||
return { success: true }
|
||||
} else {
|
||||
console.log('[COSE] 头条未找到编辑器')
|
||||
return { success: false, error: '未找到编辑器' }
|
||||
return {
|
||||
success: true,
|
||||
method: 'paste-html',
|
||||
imageCount,
|
||||
}
|
||||
}
|
||||
|
||||
return fillContent()
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectToutiaoTask(tab, helpers) {
|
||||
const currentState = inspectToutiaoTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
return ['toutiao.com', 'www.toutiao.com'].includes(url.hostname)
|
||||
&& /^\/(?:article|item|w)\/[^/]+\/?$/.test(url.pathname)
|
||||
&& (link.target === '_blank' || /查看文章|阅读文章|发布成功|已发布/.test(link.textContent || ''))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const visibleText = document.body?.innerText || ''
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(visibleText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
const state = inspectToutiaoTaskState(tab.url || '', observed?.publicUrl)
|
||||
if (state.status === 'filled' && observed?.draftSaved) {
|
||||
return {
|
||||
...state,
|
||||
status: 'draft_saved',
|
||||
message: '已确认今日头条编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日头条同步处理器
|
||||
* @param {object} tab - Chrome tab 对象
|
||||
* @param {object} content - 内容对象 { title, body, markdown }
|
||||
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
|
||||
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
|
||||
*/
|
||||
async function syncToutiaoContent(tab, content, helpers) {
|
||||
const { chrome, waitForTab } = helpers
|
||||
if (!tab?.id) return { success: false, message: '无法获取今日头条编辑器标签页' }
|
||||
|
||||
// 等待页面加载完成
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 额外等待一下让编辑器完全加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2500))
|
||||
|
||||
// 先注入公共工具函数
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 在页面中执行填充
|
||||
const result = await chrome.scripting.executeScript({
|
||||
await helpers.waitForTab(tab.id)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillToutiaoContentInPage,
|
||||
args: [content.title, content.body || content.markdown || ''],
|
||||
func: fillToutiaoContent,
|
||||
args: [content.title, content.markdown, content.body],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
const fillResult = result?.[0]?.result
|
||||
if (fillResult?.success) {
|
||||
return { success: true, message: '已打开头条号并填充内容', tabId: tab.id }
|
||||
} else {
|
||||
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '今日头条内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已打开并填充今日头条编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { ToutiaoPlatform, fillToutiaoContentInPage, syncToutiaoContent }
|
||||
export {
|
||||
ToutiaoPlatform,
|
||||
fillToutiaoContent,
|
||||
inspectToutiaoTask,
|
||||
inspectToutiaoTaskState,
|
||||
parseToutiaoPublishedUrl,
|
||||
renderToutiaoMarkdown,
|
||||
renderToutiaoPlainText,
|
||||
syncToutiaoContent,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 知乎平台配置
|
||||
const ZhihuPlatform = {
|
||||
id: 'zhihu',
|
||||
name: 'Zhihu',
|
||||
@@ -9,426 +8,284 @@ const ZhihuPlatform = {
|
||||
type: 'zhihu',
|
||||
}
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
function parseZhihuPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
// 知乎内容填充函数(在页面主世界中执行)
|
||||
// 知乎现在支持直接粘贴 Markdown,然后弹窗提示转换
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
function fillZhihuContent(title, markdown) {
|
||||
// 等待满足条件的元素出现(使用 MutationObserver)
|
||||
function waitForElement(predicate, timeout = 10000) {
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://zhuanlan.zhihu.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'zhuanlan.zhihu.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/p\/([^/?#]+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认知乎文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectZhihuTaskState(rawUrl, publicUrl) {
|
||||
const published = parseZhihuPublishedUrl(publicUrl) || parseZhihuPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取知乎标签页地址' }
|
||||
}
|
||||
|
||||
if (
|
||||
url.protocol !== 'https:'
|
||||
|| !['zhuanlan.zhihu.com', 'www.zhihu.com'].includes(url.hostname)
|
||||
) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开知乎,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (url.hostname === 'zhuanlan.zhihu.com') {
|
||||
const draft = url.pathname.match(/^\/write\/([^/?#]+)\/?$/)
|
||||
if (draft?.[1]) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draft[1],
|
||||
message: '已确认知乎草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/write\/?$/.test(url.pathname)) {
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到知乎新建编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前知乎页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillZhihuContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => (value || '').replace(/\s+/g, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const el = predicate()
|
||||
if (el) return resolve(el)
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = predicate()
|
||||
if (el) {
|
||||
observer.disconnect()
|
||||
resolve(el)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(predicate())
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// 等待按钮出现并点击
|
||||
async function waitAndClickButton(textMatcher, timeout = 5000) {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const buttons = document.querySelectorAll('button')
|
||||
for (const btn of buttons) {
|
||||
if (textMatcher(btn.textContent)) {
|
||||
btn.click()
|
||||
console.log('[COSE] 已点击按钮:', btn.textContent)
|
||||
return true
|
||||
}
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
|
||||
input.focus()
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function clearEditor(editor) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
document.execCommand('delete', false)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
inputType: 'deleteContentBackward',
|
||||
}))
|
||||
}
|
||||
|
||||
async function clickMarkdownParser() {
|
||||
const deadline = Date.now() + 5000
|
||||
while (Date.now() < deadline) {
|
||||
const button = Array.from(document.querySelectorAll('button')).find(candidate => {
|
||||
if (candidate.offsetParent === null || candidate.disabled) return false
|
||||
const text = normalizeText(candidate.textContent)
|
||||
return text === '确认并解析' || text === '确认并转换'
|
||||
})
|
||||
if (button) {
|
||||
button.click()
|
||||
return true
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await sleep(150)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fillContent() {
|
||||
// 第一步:等待知乎编辑器完全加载(避免"草稿加载中"提示)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 第二步:填充标题
|
||||
async function fillTitle() {
|
||||
const titleInput = await window.waitFor('textarea[placeholder*="标题"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 使用 nativeInputValueSetter 确保 React 识别变更
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(titleInput, title)
|
||||
} else {
|
||||
titleInput.value = title
|
||||
}
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 知乎标题填充成功')
|
||||
}
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'.WriteIndex-titleInput textarea',
|
||||
'.WriteIndex-titleInput input',
|
||||
])
|
||||
if (!titleInput) {
|
||||
return { success: false, error: '未找到知乎标题输入框' }
|
||||
}
|
||||
if (title) setInputValue(titleInput, title)
|
||||
|
||||
// 先填充标题
|
||||
await fillTitle()
|
||||
|
||||
// 再等待一下确保标题已保存
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 第三步:找到并激活知乎编辑器
|
||||
const editorSelectors = [
|
||||
const editor = await waitForElement([
|
||||
'.public-DraftEditor-content[contenteditable="true"]',
|
||||
'.public-DraftEditor-content',
|
||||
'[contenteditable="true"]',
|
||||
'.DraftEditor-root',
|
||||
]
|
||||
|
||||
let editor = null
|
||||
for (const selector of editorSelectors) {
|
||||
editor = document.querySelector(selector)
|
||||
if (editor) break
|
||||
}
|
||||
|
||||
'.DraftEditor-root [contenteditable="true"]',
|
||||
'.WriteIndex-content [contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
if (!editor) {
|
||||
console.log('[COSE] 未找到知乎编辑器')
|
||||
return { success: false, error: 'Editor not found' }
|
||||
return { success: false, error: '未找到知乎正文编辑器' }
|
||||
}
|
||||
|
||||
// 激活编辑器:模拟真实点击序列
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
// 触发鼠标事件序列激活编辑器
|
||||
for (const eventType of ['mousedown', 'mouseup', 'click']) {
|
||||
const event = new MouseEvent(eventType, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: centerX,
|
||||
clientY: centerY,
|
||||
button: 0,
|
||||
})
|
||||
editor.dispatchEvent(event)
|
||||
}
|
||||
|
||||
// 聚焦编辑器
|
||||
editor.focus()
|
||||
|
||||
// 清空现有内容
|
||||
document.execCommand('selectAll', false)
|
||||
document.execCommand('delete', false)
|
||||
|
||||
// 等待编辑器状态更新
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 第三步:通过剪贴板 + 键盘事件模拟真实粘贴
|
||||
// 这是触发知乎 Markdown 检测弹窗的关键方法
|
||||
const contentToFill = markdown || ''
|
||||
|
||||
if (!contentToFill) {
|
||||
console.log('[COSE] 没有 Markdown 内容需要填充')
|
||||
await fillTitle()
|
||||
return { success: true, method: 'empty' }
|
||||
return { success: true, method: 'title-only' }
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ClipboardEvent 模拟粘贴 - 这是触发 Markdown 检测弹窗的关键
|
||||
// execCommand('insertText') 不会触发弹窗
|
||||
|
||||
// 检查浏览器兼容性
|
||||
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') {
|
||||
throw new Error('浏览器不支持 DataTransfer 或 ClipboardEvent')
|
||||
}
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/plain', contentToFill)
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
const dispatched = editor.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] 已触发 ClipboardEvent,dispatched:', dispatched)
|
||||
|
||||
// 等待 Markdown 检测弹窗出现并点击"确认并解析"
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const parseClicked = await waitAndClickButton(text => text.includes('确认并解析'), 5000)
|
||||
|
||||
if (parseClicked) {
|
||||
console.log('[COSE] 已点击"确认并解析"')
|
||||
|
||||
// 等待解析完成并点击"确认"
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const confirmClicked = await waitAndClickButton(text => text === '确认', 5000)
|
||||
|
||||
if (confirmClicked) {
|
||||
console.log('[COSE] 已点击"确认",Markdown 解析完成')
|
||||
}
|
||||
} else {
|
||||
console.log('[COSE] 未检测到 Markdown 弹窗')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[COSE] 内容插入失败:', err.message || err)
|
||||
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') {
|
||||
return { success: false, error: '当前浏览器无法向知乎编辑器粘贴 Markdown' }
|
||||
}
|
||||
|
||||
// 等待内容渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
editor.focus()
|
||||
clearEditor(editor)
|
||||
|
||||
return { success: true, method: 'paste-markdown' }
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/plain', contentToFill)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
|
||||
const parsed = await clickMarkdownParser()
|
||||
await sleep(parsed ? 1000 : 600)
|
||||
|
||||
const expectedTextLength = normalizeText(contentToFill
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
|
||||
.replace(/[\[\]#*_>`~()]/g, ''))
|
||||
.length
|
||||
const editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
const imageEquivalentLength = editor.querySelectorAll('img').length * 12
|
||||
if (editorTextLength + imageEquivalentLength < minimumLength) {
|
||||
return {
|
||||
success: false,
|
||||
error: '知乎未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: parsed ? 'markdown-parse' : 'paste',
|
||||
}
|
||||
}
|
||||
|
||||
return fillContent()
|
||||
return fill()
|
||||
}
|
||||
|
||||
/**
|
||||
* 知乎同步处理器
|
||||
* 知乎现在支持直接粘贴 Markdown,然后弹窗提示转换
|
||||
* @param {object} tab - Chrome tab 对象
|
||||
* @param {object} content - 内容对象 { title, body, markdown }
|
||||
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
|
||||
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
|
||||
*/
|
||||
async function syncZhihuContent(tab, content, helpers) {
|
||||
const { waitForTab } = helpers
|
||||
async function inspectZhihuTask(tab, helpers) {
|
||||
const currentState = inspectZhihuTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
// 等待页面加载完成(waitForTab 使用 chrome.tabs.onUpdated 监听)
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 激活知乎标签页(避免后台标签页限制导致填充失败)
|
||||
try {
|
||||
await chrome.tabs.update(tab.id, { active: true })
|
||||
console.log('[COSE] 已激活知乎标签页')
|
||||
// 等待标签页激活完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
} catch (err) {
|
||||
console.log('[COSE] 激活标签页失败:', err.message || err)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const link = Array.from(document.querySelectorAll('a[href]')).find(candidate => {
|
||||
try {
|
||||
const url = new URL(candidate.getAttribute('href'), location.href)
|
||||
if (url.hostname !== 'zhuanlan.zhihu.com' || !/^\/p\/[^/]+\/?$/.test(url.pathname)) {
|
||||
return false
|
||||
}
|
||||
return /查看文章|阅读文章|发布成功|已发布/.test(candidate.textContent || '')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return link ? new URL(link.getAttribute('href'), location.href).href : undefined
|
||||
},
|
||||
})
|
||||
return inspectZhihuTaskState(tab.url || '', result?.[0]?.result)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncZhihuContent(tab, content, helpers) {
|
||||
if (!tab?.id) {
|
||||
return { success: false, message: '无法获取知乎编辑器标签页' }
|
||||
}
|
||||
|
||||
// 先注入公共工具函数(waitFor 使用 MutationObserver)
|
||||
await injectUtils(globalThis.chrome, tab.id)
|
||||
|
||||
// 在页面中执行内容填充
|
||||
const result = await globalThis.chrome.scripting.executeScript({
|
||||
await helpers.waitForTab(tab.id)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillZhihuContent,
|
||||
args: [content.title, content.markdown],
|
||||
args: [content.title, content.markdown, content.body],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
const fillResult = result?.[0]?.result
|
||||
if (fillResult?.success) {
|
||||
// 等待 2 秒确保内容已保存
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 等待图片上传完成后再刷新
|
||||
console.log('[COSE] 开始监听图片上传请求...')
|
||||
const uploadComplete = await waitForImageUploadComplete(tab.id)
|
||||
|
||||
if (uploadComplete) {
|
||||
console.log('[COSE] 图片上传完成,准备刷新页面')
|
||||
try {
|
||||
if (chrome?.tabs && tab?.id) {
|
||||
await chrome.tabs.reload(tab.id, { bypassCache: false })
|
||||
console.log('[COSE] 已模拟用户刷新知乎页面')
|
||||
} else {
|
||||
console.log('[COSE] chrome.tabs 或 tab.id 不可用,跳过刷新')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[COSE] 刷新页面失败:', err.message || err)
|
||||
}
|
||||
} else {
|
||||
console.log('[COSE] 未检测到图片上传请求或超时,跳过刷新')
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '知乎内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: '已打开知乎并同步内容', tabId: tab.id }
|
||||
} else {
|
||||
return { success: false, message: fillResult?.error || '内容同步失败', tabId: tab.id }
|
||||
return {
|
||||
success: true,
|
||||
message: '已打开并填充知乎编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待图片上传完成
|
||||
* @param {number} tabId - 标签页 ID
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function waitForImageUploadComplete(tabId, timeout = 30000) {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 在页面中注入监听脚本
|
||||
const result = await globalThis.chrome.scripting.executeScript({
|
||||
target: { tabId: tabId },
|
||||
func: () => {
|
||||
return new Promise(resolve => {
|
||||
const pendingUploads = new Map() // uploadId -> { url, completed }
|
||||
let hasUploadRequests = false
|
||||
let lastUploadTime = 0
|
||||
|
||||
// 检查是否所有上传都完成
|
||||
const checkAllComplete = () => {
|
||||
if (pendingUploads.size === 0) return false
|
||||
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (!info.completed) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 监听 fetch 请求
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = function (...args) {
|
||||
const url = args[0]
|
||||
const options = args[1] || {}
|
||||
|
||||
// 检测图片上传请求(知乎的图片上传通常包含这些特征)
|
||||
const isImageUpload =
|
||||
typeof url === 'string' &&
|
||||
(url.includes('/api/v4/images') ||
|
||||
url.includes('/api/v4/upload') ||
|
||||
url.includes('upload') ||
|
||||
(options.method === 'POST' && url.includes('zhihu.com')))
|
||||
|
||||
if (isImageUpload) {
|
||||
hasUploadRequests = true
|
||||
lastUploadTime = Date.now()
|
||||
const uploadId = Date.now() + Math.random()
|
||||
pendingUploads.set(uploadId, { url, completed: false })
|
||||
console.log('[COSE] 检测到图片上传请求:', url, uploadId)
|
||||
}
|
||||
|
||||
return originalFetch
|
||||
.apply(this, args)
|
||||
.then(response => {
|
||||
if (isImageUpload) {
|
||||
console.log('[COSE] 图片上传请求完成:', url, response.status)
|
||||
// 标记为已完成
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (info.url === url) {
|
||||
info.completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
})
|
||||
.catch(error => {
|
||||
if (isImageUpload) {
|
||||
console.log('[COSE] 图片上传请求失败:', url, error)
|
||||
// 即使失败也标记为已完成(有反馈结果)
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (info.url === url) {
|
||||
info.completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
// 监听 XMLHttpRequest
|
||||
const originalOpen = XMLHttpRequest.prototype.open
|
||||
const originalSend = XMLHttpRequest.prototype.send
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
this._url = url
|
||||
this._method = method
|
||||
return originalOpen.apply(this, [method, url, ...rest])
|
||||
}
|
||||
|
||||
XMLHttpRequest.prototype.send = function (...args) {
|
||||
const isImageUpload =
|
||||
this._url &&
|
||||
(this._url.includes('/api/v4/images') ||
|
||||
this._url.includes('/api/v4/upload') ||
|
||||
this._url.includes('upload') ||
|
||||
(this._method === 'POST' && this._url.includes('zhihu.com')))
|
||||
|
||||
if (isImageUpload) {
|
||||
hasUploadRequests = true
|
||||
lastUploadTime = Date.now()
|
||||
const uploadId = Date.now() + Math.random()
|
||||
pendingUploads.set(uploadId, { url: this._url, completed: false })
|
||||
console.log('[COSE] 检测到图片上传 XHR:', this._url, uploadId)
|
||||
|
||||
this.addEventListener('loadend', () => {
|
||||
console.log('[COSE] 图片上传 XHR 完成:', this._url, this.status)
|
||||
// 标记为已完成
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (info.url === this._url) {
|
||||
info.completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return originalSend.apply(this, args)
|
||||
}
|
||||
|
||||
// 定期检查是否所有上传都完成
|
||||
const checkTimer = setInterval(() => {
|
||||
// 如果没有检测到任何上传请求,说明可能没有图片需要上传
|
||||
if (!hasUploadRequests) {
|
||||
console.log('[COSE] 未检测到图片上传请求')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果所有上传都完成,并且距离最后一个上传请求已经过去2秒(确保没有新请求)
|
||||
if (checkAllComplete() && Date.now() - lastUploadTime > 2000) {
|
||||
console.log('[COSE] 所有图片上传请求已完成')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 10秒后如果没有检测到上传请求,认为没有图片需要上传
|
||||
setTimeout(() => {
|
||||
if (!hasUploadRequests) {
|
||||
console.log('[COSE] 10秒内未检测到上传请求,认为无图片')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true)
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
// 超时后无论如何都返回
|
||||
setTimeout(() => {
|
||||
console.log('[COSE] 等待图片上传超时')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true) // 即使超时也刷新
|
||||
}, timeout)
|
||||
})
|
||||
},
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
// 等待监听结果
|
||||
const uploadResult = result?.[0]?.result
|
||||
console.log('[COSE] 图片上传监听结果:', uploadResult)
|
||||
|
||||
// 给一个额外的缓冲时间,确保图片已经完全加载和渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
return uploadResult !== false
|
||||
export {
|
||||
ZhihuPlatform,
|
||||
fillZhihuContent,
|
||||
inspectZhihuTask,
|
||||
inspectZhihuTaskState,
|
||||
syncZhihuContent,
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { ZhihuPlatform, fillZhihuContent, syncZhihuContent }
|
||||
|
||||
@@ -27,20 +27,39 @@ export const ZhihuLoginConfig = {
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.id,
|
||||
getUserInfo: response => ({
|
||||
platformUid: response?.id,
|
||||
username: response?.name,
|
||||
avatar: response?.avatar_url,
|
||||
}),
|
||||
}
|
||||
|
||||
// 头条号
|
||||
export function parseToutiaoAccount(response) {
|
||||
const data = response?.data && typeof response.data === 'object' ? response.data : response
|
||||
const user = data?.user && typeof data.user === 'object' ? data.user : undefined
|
||||
const platformUid = [
|
||||
data?.user_id,
|
||||
data?.uid,
|
||||
data?.creator_id,
|
||||
data?.account_id,
|
||||
data?.id,
|
||||
user?.user_id,
|
||||
user?.uid,
|
||||
user?.id,
|
||||
].find(value => typeof value === 'string' || typeof value === 'number')
|
||||
|
||||
return {
|
||||
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
|
||||
username: data?.name || data?.nickname || user?.name || user?.nickname,
|
||||
avatar: data?.avatar_url || data?.avatar || user?.avatar_url || user?.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
export const ToutiaoLoginConfig = {
|
||||
api: 'https://mp.toutiao.com/mp/agw/creator_center/user_info?app_id=1231',
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.code === 0 && response?.name,
|
||||
getUserInfo: response => ({
|
||||
username: response?.name,
|
||||
avatar: response?.avatar_url,
|
||||
}),
|
||||
checkLogin: response => response?.code === 0 && Boolean(parseToutiaoAccount(response).platformUid),
|
||||
getUserInfo: parseToutiaoAccount,
|
||||
}
|
||||
|
||||
// 百家号
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
export function parseJianshuAccount(json) {
|
||||
const data = json?.data
|
||||
if (!data) return null
|
||||
|
||||
const rawPlatformUid = data.id ?? data.user_id ?? data.userId ?? data.slug
|
||||
const platformUid = rawPlatformUid === undefined || rawPlatformUid === null
|
||||
? undefined
|
||||
: String(rawPlatformUid).trim() || undefined
|
||||
|
||||
return {
|
||||
platformUid,
|
||||
username: data.nickname || data.name || '',
|
||||
avatar: data.avatar || data.avatar_url || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jianshu platform detection logic
|
||||
* Strategy: Fetch settings JSON API to get nickname and avatar
|
||||
@@ -16,16 +32,16 @@ export async function detectJianshuUser() {
|
||||
if (!response.ok) return { loggedIn: false }
|
||||
|
||||
const json = await response.json()
|
||||
if (!json?.data) return { loggedIn: false }
|
||||
const account = parseJianshuAccount(json)
|
||||
if (!account) return { loggedIn: false }
|
||||
|
||||
const username = json.data.nickname || ''
|
||||
let avatar = json.data.avatar || ''
|
||||
let avatar = account.avatar
|
||||
|
||||
if (avatar && avatar.includes('jianshu.io')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://www.jianshu.com/')
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
return { ...account, loggedIn: true, avatar }
|
||||
} catch (e) {
|
||||
console.error('[COSE] Jianshu Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AiToEarn - 内容营销助手",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
ToutiaoLoginConfig,
|
||||
ZhihuLoginConfig,
|
||||
} from '../distribution/cose/detection/src/configs.js'
|
||||
import { parseJianshuAccount } from '../distribution/cose/detection/src/platforms/jianshu.js'
|
||||
import { inspectJianshuTaskState } from '../distribution/cose/core/platforms/jianshu.js'
|
||||
import { inspectJuejinTaskState } from '../distribution/cose/core/platforms/juejin.js'
|
||||
import { inspectZhihuTaskState } from '../distribution/cose/core/platforms/zhihu.js'
|
||||
import {
|
||||
inspectToutiaoTaskState,
|
||||
renderToutiaoMarkdown,
|
||||
} from '../distribution/cose/core/platforms/toutiao.js'
|
||||
import { canReturnExistingPlatformTaskState } from '../distribution/cose/core/task-state.js'
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -49,4 +60,167 @@ assert.equal(
|
||||
false,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
parseJianshuAccount({
|
||||
data: {
|
||||
id: 12345,
|
||||
nickname: '简书作者',
|
||||
avatar: 'https://upload.jianshu.io/avatar.png',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: '12345',
|
||||
username: '简书作者',
|
||||
avatar: 'https://upload.jianshu.io/avatar.png',
|
||||
},
|
||||
)
|
||||
assert.equal(parseJianshuAccount({ data: { nickname: '无稳定标识' } }).platformUid, undefined)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectJianshuTaskState('https://www.jianshu.com/writer#/notebooks/12/notes/34'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://www.jianshu.com/writer#/notebooks/12/notes/34',
|
||||
platformWorkId: '34',
|
||||
message: '已确认简书草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectJianshuTaskState(
|
||||
'https://www.jianshu.com/writer#/notebooks/12/notes/34',
|
||||
'https://www.jianshu.com/p/published-note',
|
||||
),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://www.jianshu.com/p/published-note',
|
||||
platformWorkId: 'published-note',
|
||||
message: '已确认简书文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectJianshuTaskState('https://www.jianshu.com/writer').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectJianshuTaskState('https://www.jianshu.com/writer#/notebooks/12/notes/34'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
ZhihuLoginConfig.getUserInfo({
|
||||
id: 'zhihu-user-1',
|
||||
name: '知乎作者',
|
||||
avatar_url: 'https://picx.zhimg.com/avatar.jpg',
|
||||
}),
|
||||
{
|
||||
platformUid: 'zhihu-user-1',
|
||||
username: '知乎作者',
|
||||
avatar: 'https://picx.zhimg.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
ZhihuLoginConfig.getUserInfo({ name: '无稳定标识' }).platformUid,
|
||||
undefined,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://zhuanlan.zhihu.com/write',
|
||||
message: '内容已填充到知乎新建编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write/draft-123'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://zhuanlan.zhihu.com/write/draft-123',
|
||||
platformWorkId: 'draft-123',
|
||||
message: '已确认知乎草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/p/987654'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://zhuanlan.zhihu.com/p/987654',
|
||||
platformWorkId: '987654',
|
||||
message: '已确认知乎文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectZhihuTaskState('https://www.zhihu.com/creator').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write/draft-123'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
ToutiaoLoginConfig.getUserInfo({
|
||||
code: 0,
|
||||
data: {
|
||||
user_id: 'toutiao-user-1',
|
||||
name: '头条作者',
|
||||
avatar_url: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: 'toutiao-user-1',
|
||||
username: '头条作者',
|
||||
avatar: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
ToutiaoLoginConfig.checkLogin({ code: 0, data: { name: '无稳定标识' } }),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://mp.toutiao.com/profile_v4/graphic/publish',
|
||||
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认今日头条草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectToutiaoTaskState('https://www.toutiao.com/article/987654321/'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://www.toutiao.com/article/987654321/',
|
||||
platformWorkId: '987654321',
|
||||
message: '已确认今日头条文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/articles').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draftId=789'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.match(
|
||||
renderToutiaoMarkdown('# 标题\n\n- 第一项\n- 第二项\n\n\n\n**重点**'),
|
||||
/<h1>标题<\/h1><ul><li>第一项<\/li><li>第二项<\/li><\/ul><p><img src="https:\/\/example\.com\/image\.png" alt="配图" \/><\/p><p><strong>重点<\/strong><\/p>/,
|
||||
)
|
||||
|
||||
console.log('Distribution handoff tests passed')
|
||||
|
||||
Reference in New Issue
Block a user