Compare commits

...

3 Commits

10 changed files with 1089 additions and 646 deletions
+2 -2
View File
@@ -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` 的编辑器地址记为草稿,无法确定时进入“发布结果待确认”,不会伪报成功。
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
+4 -100
View File
@@ -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 开始同步...')
+20 -4
View File
@@ -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,
}
// 导出
+203 -45
View File
@@ -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,
}
+405 -106
View File
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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,
}
+236 -379
View File
@@ -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] 已触发 ClipboardEventdispatched:', 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 }
+24 -5
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.4.0",
"version": "1.7.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
"permissions": [
"activeTab",
+174
View File
@@ -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![配图](https://example.com/image.png)\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')