Compare commits

...

11 Commits

33 changed files with 4275 additions and 1018 deletions
+1
View File
@@ -1,2 +1,3 @@
dist/
aitoearn-extension.zip
node_modules/
+9
View File
@@ -5,16 +5,25 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
- 从当前网页采集标题、正文和图片。
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动桥接,不冒充旧官方扩展的完整能力。
- 复用浏览器现有登录状态检测内容平台账号。
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
## Browser Provider
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入掘金、简书、知乎、今日头条、百家号、网易号、搜狐号和 InfoQ:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID;账号检测不读取、复制或上传平台 Cookie。
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id``www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id``www.infoq.cn/article/:id` 才记为已发布;知乎 `/write/:id`、今日头条和百家号带草稿标识的编辑器地址,以及网易号、搜狐号或 InfoQ 页面明确提示草稿保存成功时才记为草稿。InfoQ 的 `/draft/:id` 在未确认保存前只显示为已填充,无法确定时进入“发布结果待确认”,不会伪报成功。
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
扩展弹窗和后台通知中的“成功”只表示平台编辑器已打开并完成内容填充,不表示内容已经公开发布。右键菜单“提取此页面到 AiToEarn”会把最近一次提取结果暂存到浏览器本地,随后打开扩展即可继续选择平台。
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
当前互动桥接只开放受控白名单:能力查询、抖音账号核对、评论列表、回复列表和单条人工回复。作品 ID、评论 ID、账号 UID、回复内容、游标和数量均由扩展校验,网页不能向扩展传入任意请求地址。发送回复前必须由用户在 AiToEarn 界面逐条确认,扩展会再次核对当前浏览器账号与所选 AiToEarn 账号,写请求不会自动重试,也不支持批量回复、自动回复或私信。扩展优先复用当前 Chrome 中已有的抖音标签页,没有可复用标签页时才创建非激活标签页,并在抖音页面上下文内发起带现有 Cookie 的请求。
## 上游来源
多平台检测与编辑器填充逻辑基于 `doocs/cose`,固定版本和许可证见:
+1
View File
@@ -1,4 +1,5 @@
import './distribution/cose/background.js';
import './interaction/background.js';
const PREPARE_BATCH_MESSAGE = 'PREPARE_PLATFORM_BATCH';
const CAPTURE_URL_MESSAGE = 'CAPTURE_URL';
+36
View File
@@ -5,9 +5,12 @@
'getVersion',
'listPlatforms',
'checkPlatforms',
'getPlatformAccount',
'captureUrl',
'startBatch',
'syncToPlatform',
'openPlatformTask',
'inspectPlatformTask',
]);
async function sendRuntimeMessage(message) {
@@ -34,6 +37,14 @@
const response = await sendRuntimeMessage({ type: 'CHECK_PLATFORM_STATUS', platforms: selected });
return response?.status || {};
}
case 'getPlatformAccount': {
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
throw new Error('缺少目标平台');
return sendRuntimeMessage({
type: 'GET_PLATFORM_ACCOUNT',
platformId: payload.platformId,
});
}
case 'captureUrl': {
if (typeof payload?.url !== 'string' || !payload.url.trim())
throw new Error('缺少要采集的页面地址');
@@ -52,6 +63,31 @@
content: payload.content,
});
}
case 'openPlatformTask': {
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
throw new Error('缺少目标平台');
if (!payload.content || typeof payload.content !== 'object')
throw new Error('缺少分发内容');
return sendRuntimeMessage({
type: 'OPEN_PLATFORM_TASK',
platformId: payload.platformId,
expectedPlatformUid: payload.expectedPlatformUid,
tabId: payload.tabId,
content: payload.content,
});
}
case 'inspectPlatformTask': {
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
throw new Error('缺少目标平台');
if (!Number.isInteger(payload?.tabId) || payload.tabId <= 0)
throw new Error('缺少有效的平台编辑器标签页');
return sendRuntimeMessage({
type: 'INSPECT_PLATFORM_TASK',
platformId: payload.platformId,
tabId: payload.tabId,
expectedPlatformUid: payload.expectedPlatformUid,
});
}
default:
throw new Error(`不支持的分发方法: ${method}`);
}
+12
View File
@@ -44,9 +44,21 @@
getVersion: () => request('getVersion'),
listPlatforms: () => request('listPlatforms'),
checkPlatforms: platformIds => request('checkPlatforms', { platformIds }),
getPlatformAccount: platformId => request('getPlatformAccount', { platformId }),
captureUrl: url => request('captureUrl', { url }),
startBatch: () => request('startBatch'),
syncToPlatform: (platformId, content) => request('syncToPlatform', { platformId, content }),
openPlatformTask: (platformId, content, expectedPlatformUid, tabId) => request('openPlatformTask', {
platformId,
content,
expectedPlatformUid,
tabId,
}),
inspectPlatformTask: (platformId, tabId, expectedPlatformUid) => request('inspectPlatformTask', {
platformId,
tabId,
expectedPlatformUid,
}),
},
});
+185 -138
View File
@@ -1,5 +1,6 @@
// 平台配置
import { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS } from './core/platforms/index.js'
import { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS } from './core/platforms/index.js'
import { canReturnExistingPlatformTaskState } from './core/task-state.js'
import { qianfanIntercept } from './core/platforms/qianfan.js'
import { convertAvatarToBase64 } from './detection/src/utils.js'
// [DISABLED] import { fillAlipayOpenContent } from '@cose/core/src/platforms/alipayopen.js'
@@ -122,7 +123,12 @@ function sendOffscreenMessage(msg, timeoutMs = 15000) {
* then inject a script that makes the fetch with credentials: 'include'.
*/
async function tabContextFetch(siteUrl, apiUrl, options = {}) {
const { responseType = 'json', timeout = 15000 } = options
const {
responseType = 'json',
timeout = 15000,
method = 'GET',
body,
} = options
let createdTabId = null
try {
const urlObj = new URL(siteUrl)
@@ -166,12 +172,18 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
// so that credentials: 'include' sends the page's cookies
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async (fetchUrl, respType) => {
func: async (fetchUrl, respType, requestMethod, requestBody) => {
try {
const headers = {
Accept: respType === 'json' ? 'application/json' : 'text/html',
}
if (requestBody !== undefined) headers['Content-Type'] = 'application/json'
const resp = await fetch(fetchUrl, {
method: 'GET',
method: requestMethod,
credentials: 'include',
headers: { Accept: respType === 'json' ? 'application/json' : 'text/html' },
headers,
body: requestBody,
})
const status = resp.status
const finalUrl = resp.url
@@ -190,7 +202,7 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
return { error: e.message }
}
},
args: [apiUrl, responseType],
args: [apiUrl, responseType, method, body],
world: 'MAIN',
})
@@ -438,6 +450,9 @@ const COSE_MESSAGE_TYPES = new Set([
'GET_PLATFORMS',
'CHECK_PLATFORM_STATUS',
'CHECK_PLATFORM_STATUS_PROGRESSIVE',
'GET_PLATFORM_ACCOUNT',
'OPEN_PLATFORM_TASK',
'INSPECT_PLATFORM_TASK',
'START_SYNC_BATCH',
'SYNC_TO_PLATFORM',
'CACHE_USER_INFO',
@@ -484,12 +499,27 @@ async function handleMessage(request, sender) {
// 渐进式检测:每个平台检测完成后立即返回结果
checkAllPlatformsProgressive(request.platforms || PLATFORMS, sender.tab?.id)
return { started: true, total: (request.platforms || PLATFORMS).length }
case 'GET_PLATFORM_ACCOUNT':
return await getPlatformAccount(request.platformId)
case 'OPEN_PLATFORM_TASK':
return await openPlatformTask(
request.platformId,
request.content,
request.expectedPlatformUid,
request.tabId
)
case 'INSPECT_PLATFORM_TASK':
return await inspectPlatformTask(
request.platformId,
request.tabId,
request.expectedPlatformUid
)
case 'START_SYNC_BATCH':
// 开始新的同步批次,重置 tab group
currentSyncGroupId = null
return { success: true }
case 'SYNC_TO_PLATFORM':
return await syncToPlatform(request.platformId, request.content)
return await syncToPlatform(request.platformId, request.content, request.options)
case 'CACHE_USER_INFO':
// 缓存用户信息
if (request.platform === 'xiaohongshu' && request.userInfo) {
@@ -628,6 +658,139 @@ async function checkPlatformLogin(platform) {
}
return await detectUser(platform.id)
}
async function getPlatformAccount(platformId) {
const platform = PLATFORMS.find(item => item?.id === platformId)
if (!platform) {
return { success: false, error: '暂不支持该平台' }
}
const account = await detectUser(platformId)
if (!account?.loggedIn) {
return {
success: false,
loggedIn: false,
error: account?.error || `请先在当前 Chrome 中登录${platform.title || platform.name}`,
}
}
if (!account.platformUid) {
return {
success: false,
loggedIn: true,
error: '已检测到登录状态,但平台没有返回账号 UID',
}
}
return {
success: true,
loggedIn: true,
protocolVersion: 1,
platformId,
platformUid: String(account.platformUid),
displayName: account.username || String(account.platformUid),
avatarUrl: account.avatar || undefined,
extensionVersion: chrome.runtime.getManifest().version,
}
}
function validateExpectedPlatformAccount(account, expectedPlatformUid) {
if (expectedPlatformUid && account.platformUid !== expectedPlatformUid) {
return {
success: false,
error: `当前 Chrome 登录账号与任务绑定账号不一致(当前:${account.displayName}`,
code: 'ACCOUNT_MISMATCH',
account,
}
}
return null
}
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId) {
if (!content || typeof content !== 'object') {
return { success: false, error: '缺少待填充内容' }
}
const account = await getPlatformAccount(platformId)
if (!account.success) return account
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
if (mismatch) return mismatch
const inspect = INSPECT_HANDLERS[platformId]
let reusableTabId
if (inspect && Number.isInteger(existingTabId) && existingTabId > 0) {
try {
const existingTab = await chrome.tabs.get(existingTabId)
const existingState = await inspect(existingTab, { chrome })
if (canReturnExistingPlatformTaskState(existingState)) {
await chrome.tabs.update(existingTabId, { active: true })
return {
success: true,
...existingState,
protocolVersion: 1,
platformUid: account.platformUid,
displayName: account.displayName,
extensionVersion: account.extensionVersion,
tabId: existingTabId,
}
}
reusableTabId = existingTabId
} catch {}
}
const result = await syncToPlatform(platformId, content, {
active: true,
tabId: reusableTabId,
})
if (!result?.success) return result
if (result.tabId) {
await chrome.tabs.update(result.tabId, { active: true })
}
const tab = result.tabId ? await chrome.tabs.get(result.tabId) : null
const state = inspect && tab
? await inspect(tab, { chrome })
: { status: 'filled' }
return {
...result,
...state,
protocolVersion: 1,
platformUid: account.platformUid,
displayName: account.displayName,
extensionVersion: account.extensionVersion,
}
}
async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
if (!Number.isInteger(tabId) || tabId <= 0) {
return { success: false, error: '缺少有效的平台编辑器标签页' }
}
const account = await getPlatformAccount(platformId)
if (!account.success) return account
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
if (mismatch) return mismatch
const inspect = INSPECT_HANDLERS[platformId]
if (!inspect) {
return { success: false, error: '该平台暂不支持发布结果核对' }
}
let tab
try {
tab = await chrome.tabs.get(tabId)
} catch {
return { success: false, error: '平台编辑器标签页已经关闭,请重新打开并填充' }
}
const state = await inspect(tab, { chrome })
return {
success: state.status !== 'failed',
...state,
protocolVersion: 1,
platformUid: account.platformUid,
displayName: account.displayName,
extensionVersion: account.extensionVersion,
tabId,
}
}
async function pasteWithDebugger(tabId) {
const debuggee = { tabId }
@@ -690,7 +853,7 @@ async function pasteWithDebugger(tabId) {
}
// 同步到平台
async function syncToPlatform(platformId, content) {
async function syncToPlatform(platformId, content, options = {}) {
const platform = PLATFORMS.find(p => p && p.id === platformId)
if (!platform || !platform.publishUrl) {
return { success: false, message: '暂不支持该平台' }
@@ -703,10 +866,20 @@ async function syncToPlatform(platformId, content) {
const syncHandler = SYNC_HANDLERS[platformId]
if (syncHandler) {
console.log(`[COSE] 使用 ${platformId} 平台特定同步处理器`)
// 创建标签页(对于微信等需要特殊处理的平台,使用首页)
// 创建或复用任务标签页(对于微信等需要特殊处理的平台,使用首页)
const initialUrl = platformId === 'wechat' ? 'https://mp.weixin.qq.com/' : platform.publishUrl
tab = await chrome.tabs.create({ url: initialUrl, active: false })
await addTabToSyncGroup(tab.id, tab.windowId)
if (Number.isInteger(options.tabId) && options.tabId > 0) {
try {
tab = await chrome.tabs.update(options.tabId, {
url: initialUrl,
active: options.active === true,
})
} catch {}
}
if (!tab) {
tab = await chrome.tabs.create({ url: initialUrl, active: options.active === true })
await addTabToSyncGroup(tab.id, tab.windowId)
}
// 调用平台特定处理器
const helpers = {
@@ -720,90 +893,7 @@ async function syncToPlatform(platformId, content) {
// ==== 以下是原有的平台特定逻辑(待迁移)====
if (platformId === 'infoq') {
// InfoQ:需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write
try {
// 调用创建草稿 API
const response = await fetch('https://xie.infoq.cn/api/v1/draft/create', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
})
const data = await response.json()
if (data.code === 0 && data.data?.id) {
const draftId = data.data.id
const targetUrl = `https://xie.infoq.cn/draft/${draftId}`
console.log('[COSE] InfoQ 创建草稿成功,ID:', draftId)
tab = await chrome.tabs.create({ url: targetUrl, active: false })
await addTabToSyncGroup(tab.id, tab.windowId)
await waitForTab(tab.id)
} else {
console.error('[COSE] InfoQ 创建草稿失败:', data)
return { success: false, message: 'InfoQ 创建草稿失败,请确保已登录' }
}
} catch (e) {
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') {
if (platformId === 'xiaohongshu') {
// 小红书:需要先点击"新的创作"按钮,等待编辑器加载后填充
console.log('[COSE] 开始处理小红书同步...')
@@ -3695,49 +3785,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 开始同步...')
+297 -79
View File
@@ -1,5 +1,6 @@
// 百家号平台配置
const BaijiahaoPlat = {
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const BaijiahaoPlatform = {
id: 'baijiahao',
name: 'Baijiahao',
icon: 'https://pic.rmb.bdstatic.com/10e1e2b43c35577e1315f0f6aad6ba24.vnd.microsoft.icon',
@@ -9,86 +10,303 @@ const BaijiahaoPlat = {
type: 'baijiahao',
}
// 百家号内容填充函数
async function fillBaijiahaoContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
function parseBaijiahaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 1. 填充标题
// 百家号标题输入框在 .client_components_titleInput 内的 contenteditable div
await new Promise(resolve => setTimeout(resolve, 1000))
try {
const url = new URL(rawUrl, 'https://baijiahao.baidu.com')
if (url.protocol !== 'https:' || url.hostname !== 'baijiahao.baidu.com') return null
const platformWorkId = url.pathname === '/s' ? url.searchParams.get('id') : undefined
if (!platformWorkId?.trim()) return null
const titleEditor =
document.querySelector('.client_components_titleInput [contenteditable="true"]') ||
document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]') ||
document.querySelector('[class*="titleInput"] [contenteditable="true"]')
if (titleEditor) {
titleEditor.focus()
// 清空现有内容
titleEditor.innerHTML = ''
// 使用 document.execCommand 插入文本
document.execCommand('insertText', false, title)
// 如果 execCommand 不生效,使用备用方案
if (!titleEditor.textContent) {
titleEditor.innerHTML = `<p dir="auto">${title}</p>`
return {
status: 'published',
url: url.href,
platformWorkId: platformWorkId.trim(),
message: '已确认百家号文章公开地址',
}
titleEditor.dispatchEvent(new Event('input', { bubbles: true }))
titleEditor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 百家号标题填充成功')
} else {
console.log('[COSE] 百家号未找到标题输入框')
}
// 2. 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1500))
// 3. 填充正文内容
// 百家号使用 UEditor,内容在 iframe 中
const iframe = document.querySelector('iframe')
if (iframe && iframe.contentDocument) {
const iframeBody = iframe.contentDocument.body
if (iframeBody && iframeBody.contentEditable === 'true') {
iframeBody.focus()
// 将 markdown 转换为简单的 HTML 段落
const htmlContent = contentToFill
.split('\n\n')
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
iframeBody.innerHTML = htmlContent
iframeBody.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 百家号 iframe 编辑器填充成功')
return
}
}
// 尝试通过 UEditor API 填充
if (window.UE_V2 && window.UE_V2.instants && window.UE_V2.instants.ueditorInstant0) {
try {
const editor = window.UE_V2.instants.ueditorInstant0
const htmlContent = contentToFill
.split('\n\n')
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
editor.setContent(htmlContent)
console.log('[COSE] 百家号通过 UEditor API 填充成功')
return
} catch (e) {
console.log('[COSE] 百家号 UEditor API 调用失败', e)
}
}
// 降级:尝试直接操作 contenteditable
const contentEditor = document.querySelector('[contenteditable="true"]:not([class*="title"])')
if (contentEditor) {
contentEditor.focus()
contentEditor.innerHTML = contentToFill.replace(/\n/g, '<br>')
contentEditor.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 百家号 contenteditable 降级填充成功')
} else {
console.log('[COSE] 百家号未找到编辑器元素')
} catch {
return null
}
}
// 导出
export { BaijiahaoPlat as BaijiahaoPlatform, fillBaijiahaoContent }
function getBaijiahaoDraftId(url) {
for (const key of ['article_id', 'articleId', 'draft_id', 'draftId']) {
const value = url.searchParams.get(key)
if (value?.trim()) return value.trim()
}
return undefined
}
function inspectBaijiahaoTaskState(rawUrl, publicUrl) {
const published = parseBaijiahaoPublishedUrl(publicUrl) || parseBaijiahaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取百家号标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'baijiahao.baidu.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开百家号,无法确认发布结果',
}
}
if (/^\/builder\/rc\/edit\/?$/.test(url.pathname)) {
const draftId = getBaijiahaoDraftId(url)
if (draftId) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认百家号草稿地址',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到百家号编辑器,请检查后保存草稿或发布',
}
}
if (/^\/builder\/(?:rc\/)?(?:content|article|manage)/.test(url.pathname)) {
return {
status: 'publication_uncertain',
url: url.href,
message: '已离开百家号本次编辑器,请打开本次文章核对发布结果',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前百家号页面无法确认是草稿还是已发布文章',
}
}
function fillBaijiahaoContent(title, html, plainText) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
function waitForCondition(getValue, timeout = 15000) {
return new Promise(resolve => {
const existing = getValue()
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const value = getValue()
if (!value) return
observer.disconnect()
resolve(value)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(getValue())
}, timeout)
})
}
function selectContents(element) {
const ownerDocument = element.ownerDocument
const selection = ownerDocument.defaultView?.getSelection()
const range = ownerDocument.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setEditableText(element, value) {
const ownerDocument = element.ownerDocument
element.focus()
selectContents(element)
ownerDocument.execCommand?.('insertText', false, value)
if (normalizeText(element.textContent) !== normalizeText(value)) element.textContent = value
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
function setEditableHtml(element, nextHtml, nextPlainText) {
const ownerDocument = element.ownerDocument
element.focus()
selectContents(element)
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
if (!inserted || normalizeText(element.textContent).length + element.querySelectorAll('img').length * 12 < 1) {
element.innerHTML = nextHtml
}
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
function findUeditor() {
const instances = window.UE_V2?.instants
if (!instances || typeof instances !== 'object') return undefined
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
}
function findEditorIframe() {
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
const body = iframe.contentDocument?.body
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
})
}
function hasExpectedContent(text, imageCount) {
const expectedTextLength = normalizeText(plainText).length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
return normalizeText(text).length + imageCount * 12 >= minimumLength
}
async function fill() {
const titleEditor = await waitForCondition(() => (
document.querySelector('.client_components_titleInput [contenteditable="true"]')
|| document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]')
|| document.querySelector('[class*="titleInput"] [contenteditable="true"]')
|| document.querySelector('[contenteditable="true"][placeholder*="标题"]')
))
if (!titleEditor) return { success: false, error: '未找到百家号标题输入框' }
if (title) setEditableText(titleEditor, title)
if (!html) return { success: true, method: 'title-only' }
const editor = await waitForCondition(findUeditor, 7000)
if (editor) {
try {
editor.setContent(html)
editor.fireEvent?.('contentchange')
editor.fireEvent?.('selectionchange')
await sleep(500)
const serialized = editor.getContent?.() || ''
const text = editor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
const imageCount = (serialized.match(/<img\b/gi) || []).length
if (hasExpectedContent(text, imageCount)) {
return { success: true, method: 'ueditor', imageCount }
}
} catch {}
}
const iframe = await waitForCondition(findEditorIframe, 7000)
const iframeBody = iframe?.contentDocument?.body
if (iframeBody?.isContentEditable) {
setEditableHtml(iframeBody, html, plainText)
await sleep(500)
const imageCount = iframeBody.querySelectorAll('img').length
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
return { success: true, method: 'iframe', imageCount }
}
}
const contentEditor = Array.from(document.querySelectorAll('[contenteditable="true"]')).find(element => {
return element !== titleEditor && !titleEditor.contains(element) && !element.contains(titleEditor)
})
if (contentEditor) {
setEditableHtml(contentEditor, html, plainText)
await sleep(500)
const imageCount = contentEditor.querySelectorAll('img').length
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
return { success: true, method: 'contenteditable', imageCount }
}
}
return {
success: false,
error: '百家号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectBaijiahaoTask(tab, helpers) {
const currentState = inspectBaijiahaoTaskState(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 url.hostname === 'baijiahao.baidu.com'
&& url.pathname === '/s'
&& Boolean(url.searchParams.get('id'))
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(link.textContent || '')
} catch {
return false
}
})
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(document.body?.innerText || ''),
}
},
})
const observed = result?.[0]?.result
const state = inspectBaijiahaoTaskState(tab.url || '', observed?.publicUrl)
if (state.status === 'filled' && observed?.draftSaved) {
return {
...state,
status: 'draft_saved',
message: '已确认百家号编辑器显示草稿保存成功',
}
}
return state
} catch {
return currentState
}
}
async function syncBaijiahaoContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取百家号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillBaijiahaoContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '百家号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充百家号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
BaijiahaoPlatform,
fillBaijiahaoContent,
inspectBaijiahaoTask,
inspectBaijiahaoTaskState,
parseBaijiahaoPublishedUrl,
syncBaijiahaoContent,
}
+50 -11
View File
@@ -4,22 +4,46 @@ import { LOGIN_CHECK_CONFIG } from '../../detection/index.js'
// 平台元数据和同步函数从各平台文件导入
import { CSDNPlatform, syncCSDNContent } from './csdn.js'
import { JuejinPlatform, syncJuejinContent } from './juejin.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 { BaijiahaoPlatform } from './baijiahao.js'
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
import {
InfoQPlatform,
inspectInfoQTask,
syncInfoQContent,
} from './infoq.js'
import {
inspectJianshuTask,
JianshuPlatform,
syncJianshuContent,
} from './jianshu.js'
import {
BaijiahaoPlatform,
inspectBaijiahaoTask,
syncBaijiahaoContent,
} from './baijiahao.js'
import {
inspectWangyihaoTask,
WangyihaoPlatform,
syncWangyihaoContent,
} from './wangyihao.js'
import { TencentCloudPlatform } from './tencentcloud.js'
import { MediumPlatform } from './medium.js'
import { SspaiPlatform } from './sspai.js'
import { SohuPlatform } from './sohu.js'
import { inspectSohuTask, SohuPlatform, syncSohuContent } from './sohu.js'
import { BilibiliPlatform } from './bilibili.js'
import { WeiboPlatform } from './weibo.js'
import { AliyunPlatform } from './aliyun.js'
@@ -84,7 +108,7 @@ function getPlatformFiller(hostname) {
if (hostname.includes('infoq.cn')) return 'infoq'
if (hostname.includes('jianshu.com')) return 'jianshu'
if (hostname.includes('baijiahao.baidu.com')) return 'baijiahao'
if (hostname.includes('mp.163.com')) return 'wangyihao'
if (hostname.includes('mp.163.com')) return 'wangyi'
if (hostname.includes('cloud.tencent.com')) return 'tencentcloud'
if (hostname.includes('medium.com')) return 'medium'
if (hostname.includes('sspai.com')) return 'sspai'
@@ -112,11 +136,26 @@ function getPlatformFiller(hostname) {
const SYNC_HANDLERS = {
csdn: syncCSDNContent,
juejin: syncJuejinContent,
jianshu: syncJianshuContent,
wechat: syncWechatContent,
zhihu: syncZhihuContent,
toutiao: syncToutiaoContent,
wangyihao: syncWangyihaoContent,
baijiahao: syncBaijiahaoContent,
wangyi: syncWangyihaoContent,
sohu: syncSohuContent,
infoq: syncInfoQContent,
}
const INSPECT_HANDLERS = {
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
jianshu: inspectJianshuTask,
zhihu: inspectZhihuTask,
toutiao: inspectToutiaoTask,
baijiahao: inspectBaijiahaoTask,
wangyi: inspectWangyihaoTask,
sohu: inspectSohuTask,
infoq: inspectInfoQTask,
}
// 导出
export { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
export { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
+384 -38
View File
@@ -1,58 +1,404 @@
// InfoQ 平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const InfoQPlatform = {
id: 'infoq',
name: 'InfoQ',
icon: 'https://static001.infoq.cn/static/write/img/write-favicon.jpg',
url: 'https://xie.infoq.cn',
// InfoQ 需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write
publishUrl: 'https://xie.infoq.cn/draft/write', // 这个 URL 仅作为占位,实际会被动态替换
publishUrl: 'https://xie.infoq.cn/draft/write',
createDraftApi: 'https://xie.infoq.cn/api/v1/draft/create',
title: 'InfoQ',
type: 'infoq',
}
// InfoQ 内容填充函数
async function fillInfoQContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
// 填充标题
const titleInput = await waitFor(
'input[placeholder*="标题"], .title-input input, input.article-title'
)
if (titleInput) {
setInputValue(titleInput, title)
console.log('[COSE] InfoQ 标题填充成功')
const normalized = value.trim()
return normalized || undefined
}
function parseInfoQPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl, 'https://xie.infoq.cn')
if (url.protocol !== 'https:' || !['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)) {
return null
}
const published = url.pathname.match(/^\/article\/([A-Za-z0-9]+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认 InfoQ 文章公开地址',
}
} catch {
return null
}
}
function getInfoQDraftId(url) {
const draftId = normalizeString(url.pathname.match(/^\/draft\/([A-Za-z0-9_-]+)\/?$/)?.[1])
return ['list', 'manage', 'write'].includes(draftId) ? undefined : draftId
}
function inspectInfoQTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseInfoQPublishedUrl(publicUrl) || parseInfoQPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取 InfoQ 标签页地址' }
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// InfoQ 使用自定义 Vue 编辑器,通过 readMarkdown 方法填充内容
const gkEditor = document.querySelector('.gk-editor')
if (gkEditor && gkEditor.__vue__) {
const vm = gkEditor.__vue__
if (typeof vm.readMarkdown === 'function') {
try {
vm.readMarkdown(contentToFill)
console.log('[COSE] InfoQ readMarkdown 填充成功')
return
} catch (e) {
console.log('[COSE] InfoQ readMarkdown 失败:', e.message)
}
if (url.protocol !== 'https:' || !['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开 InfoQ,无法确认发布结果',
}
}
// 备用方案:尝试 CodeMirror
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] InfoQ CodeMirror 填充成功')
return
if (url.hostname === 'xie.infoq.cn') {
if (/^\/draft\/write\/?$/.test(url.pathname)) {
return {
status: 'filled',
url: url.href,
message: '已打开 InfoQ 新建草稿页,等待创建草稿后填充内容',
}
}
const draftId = getInfoQDraftId(url)
if (draftId) {
if (draftSaved) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认 InfoQ 编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到 InfoQ 编辑器,请等待保存完成或手动保存草稿',
}
}
}
console.log('[COSE] InfoQ 未找到编辑器')
return {
status: 'publication_uncertain',
url: url.href,
message: '当前 InfoQ 页面无法确认是草稿还是已发布文章',
}
}
// 导出
export { InfoQPlatform, fillInfoQContent }
function parseInfoQDraftCreation(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
return normalizeString(data?.id)
|| normalizeString(data?.draftId)
|| normalizeString(data?.draft_id)
}
async function createInfoQDraft() {
try {
const response = await fetch('/api/v1/draft/create', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
const body = await response.json().catch(() => undefined)
const draftId = body?.data?.id || body?.data?.draftId || body?.data?.draft_id
if (!response.ok || body?.code !== 0 || !draftId) {
return {
success: false,
error: body?.message || body?.msg || 'InfoQ 未返回新建草稿 ID',
}
}
return { success: true, draftId: String(draftId) }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'InfoQ 创建草稿失败',
}
}
}
function fillInfoQContent(title, markdown, html, plainText) {
const contentToFill = markdown || ''
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 existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setInputValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
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 hasExpectedContent(editor) {
const expectedTextLength = normalizeText(plainText).length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
const textLength = normalizeText(editor?.innerText || editor?.textContent).length
const imageCount = editor?.querySelectorAll?.('img').length || 0
return textLength + imageCount * 12 >= minimumLength
}
function getVueCandidates(editor) {
const root = editor.closest?.('.gk-editor') || document.querySelector('.gk-editor')
return [
root?.__vue__,
root?.__vueParentComponent?.proxy,
editor.__vue__,
editor.__vueParentComponent?.proxy,
].filter(Boolean)
}
function dispatchHtmlPaste(editor) {
editor.focus()
selectEditorContents(editor)
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
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
}
async function fill() {
const titleInput = await waitForElement([
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
'.title-input textarea',
'input.article-title',
'[contenteditable="true"][placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到 InfoQ 标题输入框' }
if (title) setInputValue(titleInput, title)
const editor = await waitForElement([
'.gk-editor [contenteditable="true"]',
'.gk-editor .ProseMirror',
'.ProseMirror[contenteditable="true"]',
'.ProseMirror',
'[contenteditable="true"][role="textbox"]',
])
if (!editor) return { success: false, error: '未找到 InfoQ 正文编辑器' }
if (!contentToFill) return { success: true, method: 'title-only' }
for (const candidate of getVueCandidates(editor)) {
for (const method of [candidate.readMarkdown, candidate.editorAPI?.readMarkdown]) {
if (typeof method !== 'function') continue
try {
await Promise.resolve(method.call(method === candidate.readMarkdown ? candidate : candidate.editorAPI, contentToFill))
await sleep(800)
if (hasExpectedContent(editor)) {
return { success: true, method: 'markdown-api', imageCount: editor.querySelectorAll('img').length }
}
} catch {}
}
}
dispatchHtmlPaste(editor)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(500)
}
if (!hasExpectedContent(editor)) {
return {
success: false,
error: 'InfoQ 未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: 'paste-html',
imageCount: editor.querySelectorAll('img').length,
}
}
return fill()
}
async function inspectInfoQTask(tab, helpers) {
const currentState = inspectInfoQTaskState(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)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return ['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)
&& /^\/article\/[A-Za-z0-9]+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} 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
return inspectInfoQTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncInfoQContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取 InfoQ 编辑器标签页' }
await helpers.waitForTab(tab.id)
const created = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: createInfoQDraft,
world: 'MAIN',
})
const draft = created?.[0]?.result
if (!draft?.success || !draft.draftId) {
return {
success: false,
message: draft?.error || 'InfoQ 创建草稿失败,请确认当前 Chrome 已登录创作平台',
tabId: tab.id,
}
}
const draftTab = await helpers.chrome.tabs.update(tab.id, {
url: `https://xie.infoq.cn/draft/${encodeURIComponent(draft.draftId)}`,
})
await helpers.waitForTab(draftTab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: draftTab.id },
func: fillInfoQContent,
args: [
content.title,
markdown,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || 'InfoQ 内容填充失败',
tabId: draftTab.id,
}
}
return {
success: true,
message: '已创建并填充 InfoQ 草稿,请检查后等待保存完成或手动发布',
tabId: draftTab.id,
}
}
export {
createInfoQDraft,
fillInfoQContent,
InfoQPlatform,
inspectInfoQTask,
inspectInfoQTaskState,
parseInfoQDraftCreation,
parseInfoQPublishedUrl,
syncInfoQContent,
}
+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,
}
+50 -1
View File
@@ -11,6 +11,55 @@ const JuejinPlatform = {
import { injectUtils } from './common.js'
function inspectJuejinTaskState(rawUrl) {
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取掘金标签页地址' }
}
if (url.protocol !== 'https:' || !['juejin.cn', 'www.juejin.cn'].includes(url.hostname)) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开掘金,无法确认发布结果',
}
}
const published = url.pathname.match(/^\/post\/([^/]+)\/?$/)
if (published?.[1]) {
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认掘金文章公开地址',
}
}
const draft = url.pathname.match(/^\/editor\/drafts\/([^/]+)\/?$/)
if (draft?.[1] && draft[1] !== 'new') {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draft[1],
message: '已确认掘金草稿地址',
}
}
if (draft?.[1] === 'new') {
return {
status: 'filled',
url: url.href,
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前掘金页面无法确认是草稿还是已发布文章',
}
}
// 掘金内容填充函数(在页面主世界中执行)
// 注意:需要先调用 injectUtils 注入 window.waitFor
function fillJuejinContent(title, markdown, body) {
@@ -86,4 +135,4 @@ async function syncJuejinContent(tab, content, helpers) {
}
// 导出
export { JuejinPlatform, fillJuejinContent, syncJuejinContent }
export { JuejinPlatform, fillJuejinContent, inspectJuejinTaskState, syncJuejinContent }
+305 -8
View File
@@ -1,19 +1,316 @@
// 搜狐号平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const SohuPlatform = {
id: 'sohu',
name: 'Sohu',
icon: 'https://statics.itc.cn/mp-new/icon/1.1/favicon.ico',
url: 'https://mp.sohu.com',
publishUrl: 'https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?contentStatus=1',
publishUrl: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
title: '搜狐号',
type: 'sohu',
}
// 搜狐号内容填充函数
// 注意:搜狐号由 syncToPlatform 单独处理,此函数作为备用
async function fillSohuContent(content, waitFor) {
console.log('[COSE] 搜狐号由 syncToPlatform 处理')
function parseSohuPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl, 'https://mp.sohu.com')
if (url.protocol !== 'https:' || url.hostname !== 'www.sohu.com') return null
const published = url.pathname.match(/^\/a\/(\d+(?:_\d+)?)\/?(?:\.html)?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认搜狐号文章公开地址',
}
} catch {
return null
}
}
// 导出
export { SohuPlatform, fillSohuContent }
function inspectSohuTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseSohuPublishedUrl(publicUrl) || parseSohuPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取搜狐号标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'mp.sohu.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开搜狐号,无法确认发布结果',
}
}
if (
/^\/mpfe\/v4\/main\/content\/article\/(?:create|edit)\/?$/.test(url.pathname)
|| /^\/mpfe\/v4\/contentManagement\/news\/addarticle\/?$/.test(url.pathname)
) {
if (draftSaved) {
return {
status: 'draft_saved',
url: url.href,
message: '已确认搜狐号编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到搜狐号编辑器,请检查后保存草稿或发布',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前搜狐号页面无法确认是草稿还是已发布文章',
}
}
function fillSohuContent(title, html, plainText) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
function waitForCondition(getValue, timeout = 15000) {
return new Promise(resolve => {
const existing = getValue()
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const value = getValue()
if (!value) return
observer.disconnect()
resolve(value)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(getValue())
}, timeout)
})
}
function selectEditorContents(editor) {
const ownerDocument = editor.ownerDocument
const selection = ownerDocument.defaultView?.getSelection()
const range = ownerDocument.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setTitleValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = input.ownerDocument.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
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 findUeditor() {
const instances = window.UE?.instants || window.UE?.instances
if (!instances || typeof instances !== 'object') return undefined
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
}
function findEditorIframe() {
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
const body = iframe.contentDocument?.body
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
})
}
function findContentEditor(titleInput) {
return Array.from(document.querySelectorAll('[contenteditable="true"], .edui-body')).find(element => {
return element !== titleInput && !titleInput.contains(element) && !element.contains(titleInput)
})
}
function hasExpectedContent(text, imageCount, expectedPlainText) {
const expectedTextLength = normalizeText(expectedPlainText).length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
return normalizeText(text).length + imageCount * 12 >= minimumLength
}
function setEditableHtml(editor, nextHtml, nextPlainText) {
const ownerDocument = editor.ownerDocument
editor.focus()
selectEditorContents(editor)
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
}
async function fill() {
const titleInput = await waitForCondition(() => {
return [
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
'.title-input textarea',
'[contenteditable="true"][placeholder*="标题"]',
].flatMap(selector => Array.from(document.querySelectorAll(selector))).find(Boolean)
})
if (!titleInput) return { success: false, error: '未找到搜狐号标题输入框' }
if (title) setTitleValue(titleInput, title)
if (!html) return { success: true, method: 'title-only' }
const ueditor = await waitForCondition(findUeditor, 7000)
if (ueditor) {
try {
ueditor.setContent(html)
ueditor.fireEvent?.('contentchange')
ueditor.fireEvent?.('selectionchange')
await sleep(500)
const serialized = ueditor.getContent?.() || ''
const text = ueditor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
const imageCount = (serialized.match(/<img\b/gi) || []).length
if (hasExpectedContent(text, imageCount, plainText)) {
return { success: true, method: 'ueditor', imageCount }
}
} catch {}
}
const iframe = await waitForCondition(findEditorIframe, 7000)
const iframeBody = iframe?.contentDocument?.body
if (iframeBody?.isContentEditable) {
setEditableHtml(iframeBody, html, plainText)
await sleep(500)
const imageCount = iframeBody.querySelectorAll('img').length
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount, plainText)) {
return { success: true, method: 'iframe', imageCount }
}
}
const contentEditor = await waitForCondition(() => findContentEditor(titleInput), 7000)
if (contentEditor) {
setEditableHtml(contentEditor, html, plainText)
await sleep(500)
const imageCount = contentEditor.querySelectorAll('img').length
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount, plainText)) {
return { success: true, method: 'contenteditable', imageCount }
}
}
return {
success: false,
error: '搜狐号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectSohuTask(tab, helpers) {
const currentState = inspectSohuTaskState(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)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'www.sohu.com'
&& /^\/a\/\d+(?:_\d+)?\/?(?:\.html)?$/.test(url.pathname)
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
visibleText,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectSohuTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
if (state.status === 'filled' && /发布成功|已发布/.test(observed?.visibleText || '')) {
return {
...state,
status: 'publication_uncertain',
message: '搜狐号页面显示发布完成,但未找到公开文章地址,请人工核对后回写结果',
}
}
return state
} catch {
return currentState
}
}
async function syncSohuContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取搜狐号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillSohuContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '搜狐号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充搜狐号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
fillSohuContent,
inspectSohuTask,
inspectSohuTaskState,
parseSohuPublishedUrl,
SohuPlatform,
syncSohuContent,
}
+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, '&amp;')
.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,
}
+275 -88
View File
@@ -1,117 +1,304 @@
// 网易号平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const WangyihaoPlatform = {
id: 'wangyihao',
id: 'wangyi',
name: 'Wangyihao',
icon: 'https://static.ws.126.net/163/f2e/news/yxybd_pc/resource/static/share-icon.png',
url: 'https://mp.163.com',
publishUrl: 'https://mp.163.com/#/article-publish',
title: '网易号',
type: 'wangyihao',
type: 'wangyi',
}
import { injectUtils } from './common.js'
function parseWangyihaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 网易号内容填充函数(在页面主世界中执行)
// 网易号使用剪贴板 HTML 粘贴到 Draft.js 编辑器
function fillWangyihaoContent(title, htmlBody) {
async function fill() {
// 1. 等待并填充标题 - 网易号使用 textarea.netease-textarea
const titleInput =
(await window.waitFor('textarea.netease-textarea', 10000)) ||
(await window.waitFor('textarea[placeholder*="标题"]', 3000))
try {
const url = new URL(rawUrl, 'https://mp.163.com')
if (url.protocol !== 'https:' || url.hostname !== 'www.163.com') return null
if (titleInput && title) {
titleInput.focus()
// 使用 native setter 来绕过 React 的受控组件
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
// 触发 React 能识别的事件
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] 网易号未找到标题输入框')
const published = url.pathname.match(/^\/dy\/article\/([A-Za-z0-9_-]+)\.html$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认网易号文章公开地址',
}
} catch {
return null
}
}
function inspectWangyihaoTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseWangyihaoPublishedUrl(publicUrl) || parseWangyihaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取网易号标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'mp.163.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开网易号,无法确认发布结果',
}
}
if (
/^#\/(?:article-publish|article-edit)(?:[/?]|$)/.test(url.hash)
|| /^\/post\/submit\/post\/?$/.test(url.pathname)
) {
if (draftSaved) {
return {
status: 'draft_saved',
url: url.href,
message: '已确认网易号编辑器显示草稿保存成功',
}
}
// 2. 等待 Draft.js 编辑器出现
const editor =
(await window.waitFor('.public-DraftEditor-content', 10000)) ||
(await window.waitFor('[contenteditable="true"]', 3000))
return {
status: 'filled',
url: url.href,
message: '内容已填充到网易号编辑器,请检查后保存草稿或发布',
}
}
if (editor && htmlBody) {
editor.focus()
return {
status: 'publication_uncertain',
url: url.href,
message: '当前网易号页面无法确认是草稿还是已发布文章',
}
}
// 清空 Draft.js 占位符
const placeholder = editor.querySelector('[data-text="true"]')
if (placeholder && placeholder.textContent.includes('请输入正文')) {
editor.innerHTML = ''
function fillWangyihaoContent(title, html, plainText) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
function findFirst(selectors, exclude) {
return selectors
.flatMap(selector => Array.from(document.querySelectorAll(selector)))
.find(element => element !== exclude)
}
function waitForElement(selectors, exclude, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors, exclude)
if (existing) {
resolve(existing)
return
}
// 通过 paste 事件注入 HTML 内容
const dt = new DataTransfer()
dt.setData('text/html', htmlBody)
dt.setData('text/plain', htmlBody.replace(/<[^>]*>/g, ''))
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt,
const observer = new MutationObserver(() => {
const element = findFirst(selectors, exclude)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors, exclude))
}, timeout)
})
}
editor.dispatchEvent(pasteEvent)
console.log('[COSE] 网易号内容已通过 paste 事件注入')
return { success: true }
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setTitleValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
console.log('[COSE] 网易号未找到编辑器元素')
return { success: false, error: 'Editor not found' }
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
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 dispatchHtmlPaste(editor, nextHtml, nextPlainText) {
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', nextHtml)
clipboardData.setData('text/plain', nextPlainText)
editor.focus()
selectEditorContents(editor)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
function hasExpectedContent(editor, expectedPlainText) {
const expectedTextLength = normalizeText(expectedPlainText).length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
const editorTextLength = normalizeText(editor.innerText || editor.textContent).length
const imageCount = editor.querySelectorAll('img').length
return editorTextLength + imageCount * 12 >= minimumLength
}
async function fill() {
const titleInput = await waitForElement([
'textarea.netease-textarea',
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
'[contenteditable="true"][placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到网易号标题输入框' }
if (title) setTitleValue(titleInput, title)
if (!html) return { success: true, method: 'title-only' }
const editor = await waitForElement([
'.public-DraftEditor-content[contenteditable="true"]',
'.public-DraftEditor-content',
'.DraftEditor-root [contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
'[contenteditable="true"][data-contents="true"]',
], titleInput)
if (!editor) return { success: false, error: '未找到网易号正文编辑器' }
dispatchHtmlPaste(editor, html, plainText)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
if (!hasExpectedContent(editor, plainText) && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
await sleep(500)
}
if (!hasExpectedContent(editor, plainText)) {
return {
success: false,
error: '网易号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: 'paste-html',
imageCount: editor.querySelectorAll('img').length,
}
}
return fill()
}
/**
* 网易号同步处理器
* 网易号使用剪贴板 HTML 粘贴到 Draft.js 编辑器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown, wechatHtml }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncWangyihaoContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
async function inspectWangyihaoTask(tab, helpers) {
const currentState = inspectWangyihaoTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
// 等待页面加载完成(waitForTab 使用 chrome.tabs.onUpdated 监听)
await waitForTab(tab.id)
// 先注入公共工具函数(waitFor 使用 MutationObserver
await injectUtils(chrome, tab.id)
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 网易号 HTML 内容长度:', htmlContent?.length || 0)
// 在页面中执行:填充标题和粘贴 HTML 内容
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillWangyihaoContent,
args: [content.title, htmlContent],
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 }
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)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'www.163.com'
&& /^\/dy\/article\/[A-Za-z0-9_-]+\.html$/.test(url.pathname)
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
visibleText,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectWangyihaoTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
if (state.status === 'filled' && /发布成功|已发布/.test(observed?.visibleText || '')) {
return {
...state,
status: 'publication_uncertain',
message: '网易号页面显示发布完成,但未找到公开文章地址,请人工核对后回写结果',
}
}
return state
} catch {
return currentState
}
}
// 导出
export { WangyihaoPlatform, fillWangyihaoContent, syncWangyihaoContent }
async function syncWangyihaoContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取网易号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillWangyihaoContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '网易号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充网易号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
WangyihaoPlatform,
fillWangyihaoContent,
inspectWangyihaoTask,
inspectWangyihaoTaskState,
parseWangyihaoPublishedUrl,
syncWangyihaoContent,
}
+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 }
+11
View File
@@ -0,0 +1,11 @@
const RETURNABLE_PLATFORM_TASK_STATUSES = new Set([
'filled',
'draft_saved',
'published',
])
function canReturnExistingPlatformTaskState(state) {
return Boolean(state?.url && RETURNABLE_PLATFORM_TASK_STATUSES.has(state.status))
}
export { canReturnExistingPlatformTaskState }
+50 -10
View File
@@ -15,6 +15,7 @@ export const JuejinLoginConfig = {
method: 'GET',
checkLogin: response => response?.err_no === 0 && response?.data?.user_id,
getUserInfo: response => ({
platformUid: response?.data?.user_id,
username: response?.data?.user_name,
avatar: response?.data?.avatar_large,
}),
@@ -26,31 +27,70 @@ 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,
}
// 百家号
export function parseBaijiahaoAccount(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 = [
user?.user_id,
user?.userId,
user?.userid,
user?.uid,
user?.uk,
user?.id,
data?.user_id,
data?.uid,
data?.uk,
data?.id,
].find(value => typeof value === 'string' || typeof value === 'number')
return {
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
username: user?.name || user?.nickname || data?.name,
avatar: user?.avatar || user?.avatar_url || data?.avatar,
}
}
export const BaijiahaoLoginConfig = {
api: 'https://baijiahao.baidu.com/builder/app/appinfo',
method: 'GET',
checkLogin: response => response?.errno === 0 && response?.data?.user?.name,
getUserInfo: response => ({
username: response?.data?.user?.name,
avatar: response?.data?.user?.avatar,
}),
checkLogin: response => response?.errno === 0 && Boolean(parseBaijiahaoAccount(response).platformUid),
getUserInfo: parseBaijiahaoAccount,
}
// 抖音
+1 -1
View File
@@ -53,7 +53,7 @@ const PLATFORM_DETECTORS = {
modelscope: detectModelScopeUser,
volcengine: detectVolcengineUser,
cnblogs: detectCnblogsUser,
wangyihao: detectWangyihaoUser,
wangyi: detectWangyihaoUser,
douban: detectDoubanUser,
}
@@ -1,41 +1,66 @@
import { convertAvatarToBase64 } from '../utils.js'
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
/**
* InfoQ platform detection logic
* Strategy: POST to /public/v1/user/get_user API to get user info
* The old /public/v1/my/menu endpoint returns 404.
* InfoQ 账号识别只接受用户接口返回的稳定 UID。
* 昵称和头像只用于展示,不能作为发布任务绑定依据。
*/
export async function detectInfoQUser() {
function parseInfoQAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
const platformUid = normalizeString(data?.uid)
|| normalizeString(data?.userId)
|| normalizeString(data?.user_id)
|| normalizeString(data?.id)
return {
platformUid,
username: normalizeString(data?.nickname) || normalizeString(data?.name) || '',
avatar: normalizeString(data?.avatar) || normalizeString(data?.avatarUrl) || '',
}
}
/**
* 在当前 Chrome 的 InfoQ 页面上下文中调用用户接口。
* 不读取、复制或上传 Cookie;浏览器仅在站点上下文中携带已有登录态。
*/
async function detectInfoQUser() {
try {
console.log('[COSE] InfoQ Detection: Starting')
const response = await fetch('https://www.infoq.cn/public/v1/user/get_user', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: 'InfoQ 账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://www.infoq.cn/',
'https://www.infoq.cn/public/v1/user/get_user',
{
responseType: 'json',
method: 'POST',
},
body: JSON.stringify({}),
})
)
const account = parseInfoQAccount(response?.body)
if (!response.ok) return { loggedIn: false }
const json = await response.json()
if (json?.code !== 0 || !json?.data?.uid) {
console.log('[COSE] InfoQ: Not logged in', json?.code)
if (
response?.status < 200
|| response?.status >= 300
|| response?.body?.code !== 0
|| !account.platformUid
) {
return { loggedIn: false }
}
const username = json.data.nickname || ''
let avatar = json.data.avatar || ''
if (avatar && avatar.includes('geekbang.org')) {
avatar = await convertAvatarToBase64(avatar, 'https://www.infoq.cn/')
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : 'InfoQ 账号检测失败',
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] InfoQ Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
export { detectInfoQUser, parseInfoQAccount }
@@ -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,33 +1,75 @@
/**
* Sohu (搜狐号) platform detection logic
* Strategy:
* 1. Check ppinf cookie on mp.sohu.com
* 2. Call account list API for nickname/avatar
*/
export async function detectSohuUser() {
try {
const ppinfCookie = await chrome.cookies.get({ url: 'https://mp.sohu.com', name: 'ppinf' })
if (!ppinfCookie || !ppinfCookie.value) return { loggedIn: false }
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
try {
const response = await fetch('https://mp.sohu.com/mpbp/bp/account/list', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
const data = await response.json()
if (data.success && data.data?.data?.[0]?.accounts?.[0]) {
const account = data.data.data[0].accounts[0]
let avatar = account.avatar || ''
if (avatar.startsWith('//')) avatar = 'https:' + avatar
return { loggedIn: true, username: account.nickName, avatar }
} else {
return { loggedIn: true, username: '', avatar: '' }
}
} catch (e) {
return { loggedIn: true, username: '', avatar: '' }
}
} catch (e) {
return { loggedIn: false }
const normalized = value.trim()
return normalized || undefined
}
function getSohuAccounts(response) {
const data = response?.data
if (Array.isArray(data?.data)) {
return data.data.flatMap(group => (Array.isArray(group?.accounts) ? group.accounts : []))
}
if (Array.isArray(data?.accounts)) return data.accounts
return []
}
/**
* 搜狐号账号识别只接受账号列表返回的稳定账号标识。
* 昵称只用于展示,不能作为发布任务的账号绑定依据。
*/
function parseSohuAccount(response) {
const account = getSohuAccounts(response).find(candidate => {
return Boolean(
normalizeString(candidate?.accountId)
|| normalizeString(candidate?.account_id)
|| normalizeString(candidate?.id),
)
})
const platformUid = normalizeString(account?.accountId)
|| normalizeString(account?.account_id)
|| normalizeString(account?.id)
let avatar = normalizeString(account?.avatar) || normalizeString(account?.avatarUrl) || ''
if (avatar.startsWith('//')) avatar = `https:${avatar}`
return {
platformUid,
username: normalizeString(account?.nickName) || normalizeString(account?.name) || '',
avatar,
}
}
/**
* 在当前 Chrome 的搜狐号页面上下文中读取账号列表。
* 不读取、复制或上传 Cookie;浏览器只用现有站点登录态发送同源请求。
*/
async function detectSohuUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '搜狐号账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://mp.sohu.com/',
'https://mp.sohu.com/mpbp/bp/account/list',
{ responseType: 'json' },
)
const account = parseSohuAccount(response?.body)
if (response?.status < 200 || response?.status >= 300 || response?.body?.success !== true || !account.platformUid) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '搜狐号账号检测失败',
}
}
}
export { detectSohuUser, parseSohuAccount }
@@ -1,51 +1,55 @@
import { convertAvatarToBase64 } from '../utils.js'
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
/**
* 网易号 detection logic
* Strategy:
* 1. Collect cookies via chrome.cookies.getAll (MV3 service worker compatible)
* 2. Fetch user info via mp.163.com/wemedia/navinfo.do with cookies attached manually
* 3. Extract username and avatar from API response
* 网易号账号识别只接受创作者侧返回的 wemediaId。
* 昵称可能变化且并不唯一,不能作为账号绑定或发布交接的身份标识。
*/
export async function detectWangyihaoUser() {
try {
const cookies = await chrome.cookies.getAll({ domain: '.163.com' })
const mpCookies = await chrome.cookies.getAll({ url: 'https://mp.163.com' })
const allCookies = [...cookies, ...mpCookies]
const seen = new Set()
const uniqueCookies = allCookies.filter(c => {
const key = `${c.name}=${c.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
function parseWangyihaoAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
const platformUid = normalizeString(data?.wemediaId)
if (!cookieStr) return { loggedIn: false }
const response = await fetch(`https://mp.163.com/wemedia/navinfo.do?_=${Date.now()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
Cookie: cookieStr,
},
})
if (!response.ok) return { loggedIn: false }
const data = await response.json()
if (data?.code !== 1 || !data?.data?.wemediaId) return { loggedIn: false }
const username = data.data.tname || ''
let avatar = data.data.icon || ''
if (avatar && (avatar.includes('126.net') || avatar.includes('163.com'))) {
avatar = await convertAvatarToBase64(avatar, 'https://mp.163.com/')
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] Wangyihao Detection Error:', e)
return { loggedIn: false, error: e.message }
return {
platformUid,
username: normalizeString(data?.tname) || normalizeString(data?.name) || '',
avatar: normalizeString(data?.icon) || '',
}
}
/**
* 在当前 Chrome 的网易号页面上下文中调用官方账号接口。
* 不读取、复制或上传 Cookie;浏览器仅在站点上下文中自行携带现有登录态。
*/
async function detectWangyihaoUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '网易号账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://mp.163.com/',
`https://mp.163.com/wemedia/navinfo.do?_=${Date.now()}`,
{ responseType: 'json' },
)
const account = parseWangyihaoAccount(response?.body)
if (response?.status < 200 || response?.status >= 300 || response?.body?.code !== 1 || !account.platformUid) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '网易号账号检测失败',
}
}
}
export { detectWangyihaoUser, parseWangyihaoAccount }
+51
View File
@@ -0,0 +1,51 @@
(function bridgeAiToEarnInteraction() {
const requestSource = 'aitoearn-interaction-web';
const responseSource = 'aitoearn-interaction-extension';
const allowedMethods = new Set([
'getCapabilities',
'getDouyinAccount',
'listDouyinComments',
'listDouyinReplies',
'replyDouyinComment',
]);
async function handle(method, payload) {
const response = await chrome.runtime.sendMessage({
type: 'AITO_EARN_INTERACTION',
method,
payload,
});
if (!response?.success) {
const error = new Error(response?.error?.message || 'AiToEarn 互动扩展请求失败');
error.code = response?.error?.code || 'INTERACTION_REQUEST_FAILED';
throw error;
}
return response.result;
}
window.addEventListener('message', async (event) => {
if (
event.source !== window
|| event.origin !== window.location.origin
|| event.data?.source !== requestSource
) return;
const { requestId, method, payload } = event.data;
if (!requestId || !allowedMethods.has(method)) return;
try {
const result = await handle(method, payload);
window.postMessage({ source: responseSource, requestId, result }, window.location.origin);
} catch (error) {
window.postMessage({
source: responseSource,
requestId,
error: {
code: error?.code || 'INTERACTION_REQUEST_FAILED',
message: error instanceof Error ? error.message : String(error),
},
}, window.location.origin);
}
});
})();
+72
View File
@@ -0,0 +1,72 @@
(function installAiToEarnInteractionBridge() {
if (window.AIToEarnInteraction) return;
const requestSource = 'aitoearn-interaction-web';
const responseSource = 'aitoearn-interaction-extension';
const pending = new Map();
const defaultTimeoutMs = 60000;
const methodTimeoutMs = Object.freeze({
replyDouyinComment: 120000,
});
window.addEventListener('message', (event) => {
if (
event.source !== window
|| event.origin !== window.location.origin
|| event.data?.source !== responseSource
) return;
const entry = pending.get(event.data.requestId);
if (!entry) return;
pending.delete(event.data.requestId);
if (event.data.error) {
const error = new Error(event.data.error.message || 'AiToEarn 互动扩展请求失败');
error.code = event.data.error.code || 'INTERACTION_REQUEST_FAILED';
entry.reject(error);
return;
}
entry.resolve(event.data.result);
});
function request(method, payload) {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
const timeoutMs = methodTimeoutMs[method] || defaultTimeoutMs;
const timeout = window.setTimeout(() => {
pending.delete(requestId);
const error = new Error('AiToEarn 互动扩展响应超时');
error.code = 'INTERACTION_RESPONSE_TIMEOUT';
reject(error);
}, timeoutMs);
pending.set(requestId, {
resolve(value) {
window.clearTimeout(timeout);
resolve(value);
},
reject(error) {
window.clearTimeout(timeout);
reject(error);
},
});
window.postMessage({ source: requestSource, requestId, method, payload }, window.location.origin);
});
}
Object.defineProperty(window, 'AIToEarnInteraction', {
configurable: false,
enumerable: false,
writable: false,
value: {
getCapabilities: () => request('getCapabilities'),
getDouyinAccount: () => request('getDouyinAccount'),
listDouyinComments: payload => request('listDouyinComments', payload),
listDouyinReplies: payload => request('listDouyinReplies', payload),
replyDouyinComment: payload => request('replyDouyinComment', payload),
},
});
window.dispatchEvent(new CustomEvent('aitoearn:interaction-ready'));
})();
+238
View File
@@ -0,0 +1,238 @@
import {
doesDouyinAccountMatch,
executeDouyinCommentRequest,
executeDouyinReplyRequest,
executeDouyinSessionRequest,
InteractionValidationError,
normalizeDouyinRequest,
} from './douyin.js';
import { createInteractionCapabilities } from './capabilities.js';
const MESSAGE_TYPE = 'AITO_EARN_INTERACTION';
const ALLOWED_APP_ORIGINS = new Set([
'http://localhost:6061',
'http://127.0.0.1:6061',
'https://wx.frp.it1024.cc',
]);
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
function createError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function isAllowedSender(sender) {
const senderUrl = sender?.url || sender?.tab?.url;
if (!senderUrl) return false;
try {
return ALLOWED_APP_ORIGINS.has(new URL(senderUrl).origin);
} catch {
return false;
}
}
async function waitForTabReady(tabId) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const tab = await chrome.tabs.get(tabId);
if (tab.status === 'complete') return tab;
await sleep(250);
}
throw createError('DOUYIN_PAGE_TIMEOUT', '抖音页面加载超时');
}
async function acquireDouyinTab(workId) {
const workUrl = `https://www.douyin.com/video/${workId}`;
const tabs = await chrome.tabs.query({ url: ['https://www.douyin.com/*'] });
let tab = tabs.find(candidate => candidate.id && candidate.url?.startsWith(workUrl));
if (!tab) tab = tabs.find(candidate => candidate.id && !candidate.discarded);
const created = !tab;
if (!tab) tab = await chrome.tabs.create({ url: workUrl, active: false });
if (!tab.id) throw createError('DOUYIN_TAB_UNAVAILABLE', '无法创建或复用抖音标签页');
await waitForTabReady(tab.id);
return { tabId: tab.id, created };
}
async function acquireDouyinCreatorTab(allowCreate) {
const tabs = await chrome.tabs.query({ url: ['https://creator.douyin.com/*'] });
let tab = tabs.find(candidate => candidate.id && !candidate.discarded);
const created = !tab;
if (!tab && !allowCreate) {
throw createError(
'DOUYIN_CREATOR_TAB_REQUIRED',
'请先在当前浏览器打开并登录抖音创作者中心,再刷新互动管理',
);
}
if (!tab) tab = await chrome.tabs.create({ url: 'https://creator.douyin.com/', active: false });
if (!tab.id) throw createError('DOUYIN_CREATOR_TAB_UNAVAILABLE', '无法创建或复用抖音创作者中心标签页');
await waitForTabReady(tab.id);
return { tabId: tab.id, created };
}
async function readDouyinSession(includeCsrf = false, allowCreate = false) {
const tab = await acquireDouyinCreatorTab(allowCreate);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinSessionRequest,
args: [{ includeCsrf }],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_ACCOUNT_REQUEST_FAILED';
const message = response?.error?.message || '抖音账号读取失败';
if (code === 'DOUYIN_LOGIN_REQUIRED') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return response.result;
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
async function listDouyinComments(method, payload) {
const request = normalizeDouyinRequest(method, payload);
const tab = await acquireDouyinTab(request.workId);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinCommentRequest,
args: [{
...request,
kind: method === 'listDouyinReplies' ? 'replies' : 'comments',
}],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_REQUEST_FAILED';
const message = response?.error?.message || '抖音评论读取失败';
if (code === 'DOUYIN_LOGIN_REQUIRED') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return {
...response.result,
reusedTab: !tab.created,
};
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
async function replyDouyinComment(payload) {
const request = normalizeDouyinRequest('replyDouyinComment', payload);
const session = await readDouyinSession(true, true);
if (!doesDouyinAccountMatch(request.expectedAccountUid, session.account)) {
throw createError(
'DOUYIN_ACCOUNT_MISMATCH',
`当前浏览器登录的是“${session.account.nickname || session.account.uid}”,与所选 AiToEarn 账号不一致`,
);
}
const tab = await acquireDouyinTab(request.workId);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinReplyRequest,
args: [{ ...request, csrfToken: session.csrfToken }],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_REPLY_REQUEST_FAILED';
const message = response?.error?.message || '抖音评论回复失败';
if (code === 'DOUYIN_LOGIN_REQUIRED' || code === 'DOUYIN_RISK_CONTROL') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return {
...response.result,
account: session.account,
reusedTab: !tab.created,
};
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
function getCapabilities() {
return createInteractionCapabilities(chrome.runtime.getManifest().version);
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request?.type !== MESSAGE_TYPE) return false;
if (!isAllowedSender(sender)) {
sendResponse({
success: false,
error: { code: 'INTERACTION_ORIGIN_DENIED', message: '当前页面无权调用互动扩展' },
});
return false;
}
const method = request.method;
if (![
'getCapabilities',
'getDouyinAccount',
'listDouyinComments',
'listDouyinReplies',
'replyDouyinComment',
].includes(method)) {
sendResponse({
success: false,
error: { code: 'INTERACTION_METHOD_DENIED', message: '不支持的互动扩展方法' },
});
return false;
}
Promise.resolve(
method === 'getCapabilities'
? getCapabilities()
: method === 'getDouyinAccount'
? readDouyinSession(false, false).then(session => session.account)
: method === 'replyDouyinComment'
? replyDouyinComment(request.payload)
: listDouyinComments(method, request.payload),
)
.then(result => sendResponse({ success: true, result }))
.catch((error) => {
sendResponse({
success: false,
error: {
code: error?.code || (error instanceof InteractionValidationError
? 'INVALID_INTERACTION_PAYLOAD'
: 'INTERACTION_REQUEST_FAILED'),
message: error instanceof Error ? error.message : String(error),
},
});
});
return true;
});
+16
View File
@@ -0,0 +1,16 @@
export function createInteractionCapabilities(version) {
return {
version,
source: 'aitoearn-interaction',
platforms: {
douyin: {
comments: {
list: true,
replies: true,
create: false,
manualReply: true,
},
},
},
};
}
+430
View File
@@ -0,0 +1,430 @@
const ID_PATTERN = /^\d{1,32}$/;
const CURSOR_PATTERN = /^\d{1,16}$/;
const DEFAULT_COUNT = 20;
const MAX_COUNT = 20;
const MAX_REPLY_LENGTH = 500;
export class InteractionValidationError extends Error {
constructor(code, message) {
super(message);
this.name = 'InteractionValidationError';
this.code = code;
}
}
function normalizeId(value, fieldName) {
const id = typeof value === 'number' ? String(value) : String(value || '').trim();
if (!ID_PATTERN.test(id)) {
throw new InteractionValidationError('INVALID_DOUYIN_ID', `${fieldName} 必须是纯数字 ID`);
}
return id;
}
function normalizeCursor(value) {
if (value === undefined || value === null || value === '') return '0';
const cursor = typeof value === 'number' ? String(value) : String(value).trim();
if (!CURSOR_PATTERN.test(cursor)) {
throw new InteractionValidationError('INVALID_DOUYIN_CURSOR', '分页游标必须是非负整数');
}
return cursor;
}
function normalizeCount(value) {
if (value === undefined || value === null || value === '') return DEFAULT_COUNT;
const count = Number(value);
if (!Number.isInteger(count) || count < 1) {
throw new InteractionValidationError('INVALID_DOUYIN_COUNT', '每页数量必须是正整数');
}
return Math.min(count, MAX_COUNT);
}
function normalizeReplyContent(value) {
const content = typeof value === 'string' ? value.trim() : '';
if (!content) {
throw new InteractionValidationError('EMPTY_DOUYIN_REPLY', '回复内容不能为空');
}
if (content.length > MAX_REPLY_LENGTH) {
throw new InteractionValidationError(
'DOUYIN_REPLY_TOO_LONG',
`回复内容不能超过 ${MAX_REPLY_LENGTH} 个字符`,
);
}
return content;
}
function normalizeExpectedAccountUid(value) {
const uid = typeof value === 'string' ? value.trim() : '';
if (!uid || uid.length > 256 || /[\u0000-\u001f\u007f]/.test(uid)) {
throw new InteractionValidationError('INVALID_DOUYIN_ACCOUNT_UID', '缺少有效的抖音账号 UID');
}
return uid;
}
export function doesDouyinAccountMatch(expectedAccountUid, account) {
const expected = typeof expectedAccountUid === 'string' ? expectedAccountUid.trim() : '';
if (!expected || !account || typeof account !== 'object') return false;
return [account.uid, account.numericUid, account.uniqueId]
.some(value => typeof value === 'string' && value === expected);
}
export async function executeDouyinSessionRequest(options = {}) {
function getAvatarUrl(user) {
return [
...(user?.avatar_thumb?.url_list || []),
...(user?.avatar_medium?.url_list || []),
].find(url => typeof url === 'string' && url.startsWith('https://')) || '';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const userResponse = await fetch('/web/api/media/user/info/', {
credentials: 'include',
headers: { accept: 'application/json, text/plain, */*' },
signal: controller.signal,
});
if (!userResponse.ok) {
return {
ok: false,
error: {
code: userResponse.status === 401 || userResponse.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_ACCOUNT_HTTP_ERROR',
message: `抖音账号接口返回 HTTP ${userResponse.status}`,
},
};
}
const data = await userResponse.json();
const user = data?.user || data?.user_info;
if (Number(data?.status_code) !== 0 || !user) {
return {
ok: false,
error: {
code: 'DOUYIN_LOGIN_REQUIRED',
message: data?.status_msg || '当前浏览器尚未登录抖音创作者中心',
},
};
}
const account = {
uid: String(user.sec_uid || user.uid || ''),
numericUid: String(user.uid || ''),
uniqueId: String(user.unique_id || ''),
nickname: typeof user.nickname === 'string' ? user.nickname : '',
avatarUrl: getAvatarUrl(user),
};
if (!account.uid) {
return {
ok: false,
error: {
code: 'DOUYIN_ACCOUNT_ID_MISSING',
message: '抖音账号接口未返回可核对的账号 UID',
},
};
}
if (!options.includeCsrf) {
return { ok: true, result: { account } };
}
const csrfResponse = await fetch('/web/api/media/anchor/search', {
method: 'HEAD',
credentials: 'include',
headers: {
accept: '*/*',
'X-Secsdk-Csrf-Request': '1',
'X-Secsdk-Csrf-Version': '1.2.22',
},
signal: controller.signal,
});
const csrfHeader = csrfResponse.headers.get('x-ware-csrf-token') || '';
const csrfParts = csrfHeader.split(',').map(value => value.trim()).filter(Boolean);
const csrfToken = csrfParts[1] || csrfParts[0] || '';
if (!csrfResponse.ok || !csrfToken) {
return {
ok: false,
error: {
code: 'DOUYIN_CSRF_TOKEN_FAILED',
message: '无法获取抖音回复所需的安全令牌,请刷新创作者中心后重试',
},
};
}
return { ok: true, result: { account, csrfToken } };
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError'
? 'DOUYIN_ACCOUNT_REQUEST_TIMEOUT'
: 'DOUYIN_ACCOUNT_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音账号读取超时,请刷新创作者中心后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
export async function executeDouyinReplyRequest(request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const body = new FormData();
body.set('aweme_id', request.workId);
body.set('reply_id', request.commentId);
body.set('text', request.content);
body.set('one_level_comment_rank', '1');
const response = await fetch('/aweme/v1/web/comment/publish/?aid=6383', {
method: 'POST',
credentials: 'include',
headers: {
accept: 'application/json, text/plain, */*',
'x-secsdk-csrf-token': request.csrfToken,
},
body,
signal: controller.signal,
});
if (!response.ok) {
return {
ok: false,
error: {
code: response.status === 401 || response.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_REPLY_HTTP_ERROR',
message: `抖音回复接口返回 HTTP ${response.status}`,
},
};
}
const data = await response.json();
if (Number(data?.status_code) !== 0) {
const message = data?.status_msg || `抖音回复接口返回状态 ${data?.status_code}`;
return {
ok: false,
error: {
code: /验证|频繁|风险|安全/.test(message)
? 'DOUYIN_RISK_CONTROL'
: /登录/.test(message)
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_REPLY_API_ERROR',
message,
},
};
}
return {
ok: true,
result: {
platform: 'douyin',
workId: request.workId,
parentCommentId: request.commentId,
platformCommentId: String(data?.comment?.cid || data?.cid || ''),
success: true,
source: 'douyin-web',
},
};
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError' ? 'DOUYIN_REPLY_TIMEOUT' : 'DOUYIN_REPLY_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音回复请求超时,请确认页面可正常访问后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
export function normalizeDouyinRequest(method, payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new InteractionValidationError('INVALID_INTERACTION_PAYLOAD', '互动请求参数格式不正确');
}
const workId = normalizeId(payload.workId, '作品 ID');
if (method === 'replyDouyinComment') {
return {
workId,
commentId: normalizeId(payload.commentId, '评论 ID'),
content: normalizeReplyContent(payload.content),
expectedAccountUid: normalizeExpectedAccountUid(payload.expectedAccountUid),
};
}
const normalized = {
workId,
cursor: normalizeCursor(payload.cursor),
count: normalizeCount(payload.count),
};
if (method === 'listDouyinReplies') {
normalized.commentId = normalizeId(payload.commentId, '评论 ID');
}
return normalized;
}
export async function executeDouyinCommentRequest(request) {
function getAvatarUrl(user) {
const candidates = [
...(user?.avatar_thumb?.url_list || []),
...(user?.avatar_medium?.url_list || []),
];
return candidates.find(url => typeof url === 'string' && url.startsWith('https://')) || '';
}
function toFiniteNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function normalizeComment(comment, parentCommentId) {
const id = String(comment?.cid || '');
const nestedReplies = Array.isArray(comment?.reply_comment)
? comment.reply_comment.map(reply => normalizeComment(reply, id)).filter(reply => reply.id)
: [];
const createdAtSeconds = toFiniteNumber(comment?.create_time);
return {
id,
workId: String(comment?.aweme_id || request.workId),
parentCommentId: parentCommentId || undefined,
rootCommentId: String(parentCommentId || comment?.root_comment_id || id || ''),
text: typeof comment?.text === 'string' ? comment.text : '',
createdAt: createdAtSeconds > 0 ? new Date(createdAtSeconds * 1000).toISOString() : undefined,
likeCount: toFiniteNumber(comment?.digg_count),
replyCount: toFiniteNumber(
parentCommentId
? comment?.reply_comment_total
: comment?.reply_comment_total ?? comment?.comment_reply_total,
nestedReplies.length,
),
ipLabel: typeof comment?.ip_label === 'string' ? comment.ip_label : '',
isAuthor: comment?.label_text === '作者' || Number(comment?.label_type) === 1,
author: {
name: typeof comment?.user?.nickname === 'string' ? comment.user.nickname : '',
uid: String(comment?.user?.uid || ''),
secUid: String(comment?.user?.sec_uid || ''),
avatarUrl: getAvatarUrl(comment?.user),
},
replies: nestedReplies,
};
}
function getBrowserVersion() {
const match = navigator.userAgent.match(/(?:Chrome|Edg)\/(\d+(?:\.\d+){0,3})/);
return match?.[1] || '';
}
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const commonParams = {
device_platform: 'webapp',
aid: '6383',
channel: 'channel_pc_web',
item_type: '0',
cut_version: '1',
update_version_code: '170400',
pc_client_type: '1',
pc_libra_divert: 'Windows',
support_h265: '1',
support_dash: '1',
cpu_core_num: String(navigator.hardwareConcurrency || 8),
version_code: '170400',
version_name: '17.4.0',
cookie_enabled: String(navigator.cookieEnabled),
screen_width: String(screen.width || 1920),
screen_height: String(screen.height || 1080),
browser_language: navigator.language || 'zh-CN',
browser_platform: navigator.platform || 'Win32',
browser_name: navigator.userAgent.includes('Edg/') ? 'Edge' : 'Chrome',
browser_version: getBrowserVersion(),
browser_online: String(navigator.onLine),
engine_name: 'Blink',
os_name: 'Windows',
device_memory: String(navigator.deviceMemory || 8),
platform: 'PC',
downlink: String(connection?.downlink || 10),
effective_type: connection?.effectiveType || '4g',
round_trip_time: String(connection?.rtt || 50),
};
const isReplies = request.kind === 'replies';
const params = new URLSearchParams({
...commonParams,
cursor: request.cursor,
count: String(request.count),
...(isReplies
? { item_id: request.workId, comment_id: request.commentId }
: { aweme_id: request.workId }),
});
const endpoint = isReplies
? '/aweme/v1/web/comment/list/reply/'
: '/aweme/v1/web/comment/list/';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const response = await fetch(`${endpoint}?${params}`, {
credentials: 'include',
headers: { accept: 'application/json, text/plain, */*' },
signal: controller.signal,
});
if (!response.ok) {
return {
ok: false,
error: {
code: response.status === 401 || response.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_HTTP_ERROR',
message: `抖音评论接口返回 HTTP ${response.status}`,
},
};
}
const data = await response.json();
if (Number(data?.status_code) !== 0) {
return {
ok: false,
error: {
code: 'DOUYIN_API_ERROR',
message: data?.status_msg || `抖音评论接口返回状态 ${data?.status_code}`,
},
};
}
return {
ok: true,
result: {
platform: 'douyin',
workId: request.workId,
parentCommentId: isReplies ? request.commentId : undefined,
items: (Array.isArray(data.comments) ? data.comments : [])
.map(comment => normalizeComment(comment, isReplies ? request.commentId : undefined))
.filter(comment => comment.id),
cursor: String(data.cursor ?? ''),
hasMore: Boolean(data.has_more),
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
source: 'douyin-web',
},
};
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError' ? 'DOUYIN_REQUEST_TIMEOUT' : 'DOUYIN_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音评论请求超时,请确认页面可以正常访问后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.2.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测多平台文章分发",
"version": "1.11.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测多平台文章分发与受控人工互动",
"permissions": [
"activeTab",
"clipboardRead",
@@ -83,7 +83,7 @@
"http://127.0.0.1:6061/*",
"https://wx.frp.it1024.cc/*"
],
"js": ["distribution-page-bridge.js"],
"js": ["distribution-page-bridge.js", "interaction-page-bridge.js"],
"run_at": "document_start",
"world": "MAIN"
},
@@ -93,7 +93,7 @@
"http://127.0.0.1:6061/*",
"https://wx.frp.it1024.cc/*"
],
"js": ["distribution-bridge.js"],
"js": ["distribution-bridge.js", "interaction-bridge.js"],
"run_at": "document_start"
},
{
+9
View File
@@ -0,0 +1,9 @@
{
"name": "aitoearn-chrome-extension",
"private": true,
"type": "module",
"scripts": {
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs",
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1"
}
}
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
+27 -2
View File
@@ -17,6 +17,8 @@ $requiredPaths = @(
'content.js',
'distribution-bridge.js',
'distribution-page-bridge.js',
'interaction-bridge.js',
'interaction-page-bridge.js',
'popup.html',
'popup.css',
'popup.js',
@@ -24,7 +26,8 @@ $requiredPaths = @(
'options.css',
'options.js',
'icons',
'distribution'
'distribution',
'interaction'
)
foreach ($relativePath in $requiredPaths) {
@@ -47,6 +50,16 @@ foreach ($javascriptFile in $javascriptFiles) {
}
}
& node (Join-Path $PSScriptRoot 'test-interaction.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Interaction bridge validation failed'
}
& node (Join-Path $PSScriptRoot 'test-distribution.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Distribution bridge validation failed'
}
$distRoot = Join-Path $repoRoot 'dist'
$stageRoot = Join-Path $distRoot "aitoearn-extension-v$version"
$archivePath = Join-Path $distRoot "aitoearn-extension-v$version.zip"
@@ -89,7 +102,19 @@ if (-not (Test-Path -LiteralPath (Join-Path $verificationRoot 'manifest.json')))
throw 'Packaged archive does not contain manifest.json at its root'
}
$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$archiveStream = $null
try {
$archiveStream = [System.IO.File]::OpenRead($archivePath)
$archiveHashBytes = $sha256.ComputeHash($archiveStream)
}
finally {
if ($null -ne $archiveStream) {
$archiveStream.Dispose()
}
$sha256.Dispose()
}
$archiveHash = -join ($archiveHashBytes | ForEach-Object { $_.ToString('x2') })
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Remove-Item -LiteralPath $verificationRoot -Recurse -Force
Remove-Item -LiteralPath $stageRoot -Recurse -Force
+505
View File
@@ -0,0 +1,505 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import {
BaijiahaoLoginConfig,
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 { inspectBaijiahaoTaskState } from '../distribution/cose/core/platforms/baijiahao.js'
import { parseWangyihaoAccount } from '../distribution/cose/detection/src/platforms/wangyihao.js'
import { inspectWangyihaoTaskState } from '../distribution/cose/core/platforms/wangyihao.js'
import { parseSohuAccount } from '../distribution/cose/detection/src/platforms/sohu.js'
import { inspectSohuTaskState } from '../distribution/cose/core/platforms/sohu.js'
import { parseInfoQAccount } from '../distribution/cose/detection/src/platforms/infoq.js'
import {
inspectInfoQTaskState,
parseInfoQDraftCreation,
} from '../distribution/cose/core/platforms/infoq.js'
import {
getPlatformFiller,
INSPECT_HANDLERS,
SYNC_HANDLERS,
} from '../distribution/cose/core/platforms/index.js'
import { canReturnExistingPlatformTaskState } from '../distribution/cose/core/task-state.js'
assert.deepEqual(
inspectJuejinTaskState('https://juejin.cn/editor/drafts/new'),
{
status: 'filled',
url: 'https://juejin.cn/editor/drafts/new',
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectJuejinTaskState('https://juejin.cn/editor/drafts/123456'),
{
status: 'draft_saved',
url: 'https://juejin.cn/editor/drafts/123456',
platformWorkId: '123456',
message: '已确认掘金草稿地址',
},
)
assert.deepEqual(
inspectJuejinTaskState('https://juejin.cn/post/987654'),
{
status: 'published',
url: 'https://juejin.cn/post/987654',
platformWorkId: '987654',
message: '已确认掘金文章公开地址',
},
)
assert.equal(
inspectJuejinTaskState('https://juejin.cn/creator/content/article').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/editor/drafts/new')),
true,
)
assert.equal(
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/post/987654')),
true,
)
assert.equal(
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/creator/content/article')),
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>/,
)
assert.deepEqual(
BaijiahaoLoginConfig.getUserInfo({
errno: 0,
data: {
user: {
uk: 123456,
name: '百家号作者',
avatar: 'https://himg.bdimg.com/avatar.jpg',
},
},
}),
{
platformUid: '123456',
username: '百家号作者',
avatar: 'https://himg.bdimg.com/avatar.jpg',
},
)
assert.equal(
BaijiahaoLoginConfig.checkLogin({ errno: 0, data: { user: { name: '无稳定标识' } } }),
false,
)
assert.deepEqual(
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/edit?type=news'),
{
status: 'filled',
url: 'https://baijiahao.baidu.com/builder/rc/edit?type=news',
message: '内容已填充到百家号编辑器,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/edit?article_id=456'),
{
status: 'draft_saved',
url: 'https://baijiahao.baidu.com/builder/rc/edit?article_id=456',
platformWorkId: '456',
message: '已确认百家号草稿地址',
},
)
assert.deepEqual(
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/s?id=123456789'),
{
status: 'published',
url: 'https://baijiahao.baidu.com/s?id=123456789',
platformWorkId: '123456789',
message: '已确认百家号文章公开地址',
},
)
assert.equal(
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/manager').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/edit?draftId=789'),
),
true,
)
assert.deepEqual(
parseWangyihaoAccount({
code: 1,
data: {
wemediaId: 123456,
tname: '网易号作者',
icon: 'https://img.163.com/avatar.jpg',
},
}),
{
platformUid: '123456',
username: '网易号作者',
avatar: 'https://img.163.com/avatar.jpg',
},
)
assert.equal(
parseWangyihaoAccount({ code: 1, data: { tname: '无稳定标识' } }).platformUid,
undefined,
)
assert.deepEqual(
inspectWangyihaoTaskState('https://mp.163.com/#/article-publish'),
{
status: 'filled',
url: 'https://mp.163.com/#/article-publish',
message: '内容已填充到网易号编辑器,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectWangyihaoTaskState('https://mp.163.com/#/article-publish', undefined, true),
{
status: 'draft_saved',
url: 'https://mp.163.com/#/article-publish',
message: '已确认网易号编辑器显示草稿保存成功',
},
)
assert.deepEqual(
inspectWangyihaoTaskState('https://www.163.com/dy/article/IMABCDE123.html'),
{
status: 'published',
url: 'https://www.163.com/dy/article/IMABCDE123.html',
platformWorkId: 'IMABCDE123',
message: '已确认网易号文章公开地址',
},
)
assert.equal(
inspectWangyihaoTaskState('https://mp.163.com/#/article-manage').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectWangyihaoTaskState('https://mp.163.com/#/article-publish', undefined, true),
),
true,
)
assert.equal(getPlatformFiller('mp.163.com'), 'wangyi')
assert.equal(typeof SYNC_HANDLERS.wangyi, 'function')
assert.equal(typeof INSPECT_HANDLERS.wangyi, 'function')
const wangyihaoDetectorSource = await readFile(
new URL('../distribution/cose/detection/src/platforms/wangyihao.js', import.meta.url),
'utf8',
)
assert.doesNotMatch(wangyihaoDetectorSource, /chrome\.cookies|Cookie\s*:/)
assert.deepEqual(
parseSohuAccount({
success: true,
data: {
data: [{
accounts: [{
accountId: 'sohu-account-1',
nickName: '搜狐号作者',
avatar: '//img.sohu.com/avatar.jpg',
}],
}],
},
}),
{
platformUid: 'sohu-account-1',
username: '搜狐号作者',
avatar: 'https://img.sohu.com/avatar.jpg',
},
)
assert.equal(
parseSohuAccount({ success: true, data: { data: [{ accounts: [{ nickName: '无稳定标识' }] }] } }).platformUid,
undefined,
)
assert.deepEqual(
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/create'),
{
status: 'filled',
url: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
message: '内容已填充到搜狐号编辑器,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/create', undefined, true),
{
status: 'draft_saved',
url: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
message: '已确认搜狐号编辑器显示草稿保存成功',
},
)
assert.deepEqual(
inspectSohuTaskState('https://www.sohu.com/a/123456789_123456789'),
{
status: 'published',
url: 'https://www.sohu.com/a/123456789_123456789',
platformWorkId: '123456789_123456789',
message: '已确认搜狐号文章公开地址',
},
)
assert.equal(
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/manage').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/create', undefined, true),
),
true,
)
assert.equal(getPlatformFiller('mp.sohu.com'), 'sohu')
assert.equal(typeof SYNC_HANDLERS.sohu, 'function')
assert.equal(typeof INSPECT_HANDLERS.sohu, 'function')
const sohuDetectorSource = await readFile(
new URL('../distribution/cose/detection/src/platforms/sohu.js', import.meta.url),
'utf8',
)
assert.doesNotMatch(sohuDetectorSource, /chrome\.cookies|Cookie\s*:/)
assert.deepEqual(
parseInfoQAccount({
code: 0,
data: {
uid: 123456,
nickname: 'InfoQ 作者',
avatar: 'https://static001.infoq.cn/avatar.jpg',
},
}),
{
platformUid: '123456',
username: 'InfoQ 作者',
avatar: 'https://static001.infoq.cn/avatar.jpg',
},
)
assert.equal(
parseInfoQAccount({ code: 0, data: { nickname: '无稳定标识' } }).platformUid,
undefined,
)
assert.equal(
parseInfoQDraftCreation({ code: 0, data: { id: 'infoq-draft-1' } }),
'infoq-draft-1',
)
assert.equal(parseInfoQDraftCreation({ code: 0, data: {} }), undefined)
assert.deepEqual(
inspectInfoQTaskState('https://xie.infoq.cn/draft/write'),
{
status: 'filled',
url: 'https://xie.infoq.cn/draft/write',
message: '已打开 InfoQ 新建草稿页,等待创建草稿后填充内容',
},
)
assert.deepEqual(
inspectInfoQTaskState('https://xie.infoq.cn/draft/infoq-draft-1', undefined, true),
{
status: 'draft_saved',
url: 'https://xie.infoq.cn/draft/infoq-draft-1',
platformWorkId: 'infoq-draft-1',
message: '已确认 InfoQ 编辑器显示草稿保存成功',
},
)
assert.deepEqual(
inspectInfoQTaskState('https://xie.infoq.cn/article/AbC123'),
{
status: 'published',
url: 'https://xie.infoq.cn/article/AbC123',
platformWorkId: 'AbC123',
message: '已确认 InfoQ 文章公开地址',
},
)
assert.equal(
inspectInfoQTaskState('https://xie.infoq.cn/draft/list').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectInfoQTaskState('https://xie.infoq.cn/draft/infoq-draft-1', undefined, true),
),
true,
)
assert.equal(getPlatformFiller('xie.infoq.cn'), 'infoq')
assert.equal(typeof SYNC_HANDLERS.infoq, 'function')
assert.equal(typeof INSPECT_HANDLERS.infoq, 'function')
const infoqDetectorSource = await readFile(
new URL('../distribution/cose/detection/src/platforms/infoq.js', import.meta.url),
'utf8',
)
assert.doesNotMatch(infoqDetectorSource, /chrome\.cookies|Cookie\s*:/)
console.log('Distribution handoff tests passed')
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { createInteractionCapabilities } from '../interaction/capabilities.js';
import {
doesDouyinAccountMatch,
executeDouyinReplyRequest,
executeDouyinSessionRequest,
InteractionValidationError,
normalizeDouyinRequest,
} from '../interaction/douyin.js';
assert.deepEqual(
createInteractionCapabilities('1.4.0').platforms.douyin.comments,
{ list: true, replies: true, create: false, manualReply: true },
);
assert.deepEqual(
normalizeDouyinRequest('listDouyinComments', { workId: '7570305000069549352' }),
{ workId: '7570305000069549352', cursor: '0', count: 20 },
);
assert.deepEqual(
normalizeDouyinRequest('replyDouyinComment', {
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: ' 感谢你的反馈 ',
expectedAccountUid: 'MS4wLjABAAAA_test-account',
}),
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '感谢你的反馈',
expectedAccountUid: 'MS4wLjABAAAA_test-account',
},
);
assert.equal(doesDouyinAccountMatch('sec-uid', {
uid: 'sec-uid',
numericUid: '123',
uniqueId: 'account-name',
}), true);
assert.equal(doesDouyinAccountMatch('another-account', {
uid: 'sec-uid',
numericUid: '123',
uniqueId: 'account-name',
}), false);
assert.deepEqual(
normalizeDouyinRequest('listDouyinReplies', {
workId: '7570305000069549352',
commentId: '7570539465648243462',
cursor: 20,
count: 999,
}),
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
cursor: '20',
count: 20,
},
);
for (const payload of [
{ workId: 'https://example.com/' },
{ workId: '7570305000069549352', cursor: '-1' },
{ workId: '7570305000069549352', count: 0 },
]) {
assert.throws(
() => normalizeDouyinRequest('listDouyinComments', payload),
error => error instanceof InteractionValidationError,
);
}
for (const payload of [
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '',
expectedAccountUid: 'sec-uid',
},
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: 'x'.repeat(501),
expectedAccountUid: 'sec-uid',
},
{
workId: '7570305000069549352',
commentId: 'not-an-id',
content: 'reply',
expectedAccountUid: 'sec-uid',
},
]) {
assert.throws(
() => normalizeDouyinRequest('replyDouyinComment', payload),
error => error instanceof InteractionValidationError,
);
}
const originalFetch = globalThis.fetch;
try {
const sessionCalls = [];
globalThis.fetch = async (url, options = {}) => {
sessionCalls.push({ url, options });
if (url === '/web/api/media/user/info/') {
return {
ok: true,
status: 200,
json: async () => ({
status_code: 0,
user: {
sec_uid: 'sec-uid',
uid: '123456',
unique_id: 'aitoearn-test',
nickname: 'AiToEarn 测试账号',
avatar_thumb: { url_list: ['https://example.com/avatar.png'] },
},
}),
};
}
if (url === '/web/api/media/anchor/search') {
return {
ok: true,
status: 200,
headers: new Headers({ 'x-ware-csrf-token': 'challenge,csrf-token-value' }),
};
}
throw new Error(`Unexpected session URL: ${url}`);
};
assert.deepEqual(await executeDouyinSessionRequest({ includeCsrf: true }), {
ok: true,
result: {
account: {
uid: 'sec-uid',
numericUid: '123456',
uniqueId: 'aitoearn-test',
nickname: 'AiToEarn 测试账号',
avatarUrl: 'https://example.com/avatar.png',
},
csrfToken: 'csrf-token-value',
},
});
assert.equal(sessionCalls.length, 2);
assert.equal(sessionCalls[1].options.method, 'HEAD');
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Request'], '1');
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Version'], '1.2.22');
const replyCalls = [];
globalThis.fetch = async (url, options = {}) => {
replyCalls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({
status_code: 0,
comment: { cid: 'new-comment-id' },
}),
};
};
assert.deepEqual(await executeDouyinReplyRequest({
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '感谢你的反馈',
csrfToken: 'csrf-token-value',
}), {
ok: true,
result: {
platform: 'douyin',
workId: '7570305000069549352',
parentCommentId: '7570539465648243462',
platformCommentId: 'new-comment-id',
success: true,
source: 'douyin-web',
},
});
assert.equal(replyCalls.length, 1);
assert.equal(replyCalls[0].url, '/aweme/v1/web/comment/publish/?aid=6383');
assert.equal(replyCalls[0].options.method, 'POST');
assert.equal(replyCalls[0].options.headers['x-secsdk-csrf-token'], 'csrf-token-value');
assert.equal(replyCalls[0].options.body.get('aweme_id'), '7570305000069549352');
assert.equal(replyCalls[0].options.body.get('reply_id'), '7570539465648243462');
assert.equal(replyCalls[0].options.body.get('text'), '感谢你的反馈');
assert.equal(replyCalls[0].options.body.get('one_level_comment_rank'), '1');
let failedReplyCallCount = 0;
globalThis.fetch = async () => {
failedReplyCallCount += 1;
return {
ok: false,
status: 500,
json: async () => ({}),
};
};
const failedReply = await executeDouyinReplyRequest({
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '仅发送一次',
csrfToken: 'csrf-token-value',
});
assert.equal(failedReply.ok, false);
assert.equal(failedReply.error.code, 'DOUYIN_REPLY_HTTP_ERROR');
assert.equal(failedReplyCallCount, 1);
} finally {
globalThis.fetch = originalFetch;
}
console.log('Interaction bridge validation passed.');