Compare commits

...

4 Commits

24 changed files with 1990 additions and 541 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 信息上传到服务端。当前已接入掘金、简书和知乎:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充 Markdown 编辑器。
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id` 才记为已发布;知乎 `/write/: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,
}),
},
});
+168 -102
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'
@@ -438,6 +439,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 +488,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 +647,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 +842,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 +855,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 = {
@@ -750,59 +912,6 @@ async function syncToPlatform(platformId, content) {
console.error('[COSE] InfoQ API 调用失败:', e)
return { success: false, message: 'InfoQ API 调用失败: ' + e.message }
}
} else if (platformId === 'jianshu') {
// 简书:需要先获取文集列表,然后创建新文章
try {
// 获取用户的文集列表
const notebooksResp = await fetch('https://www.jianshu.com/author/notebooks', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
const notebooks = await notebooksResp.json()
if (!notebooks || notebooks.length === 0) {
return { success: false, message: '简书未找到文集,请先创建一个文集' }
}
// 使用第一个文集
const notebookId = notebooks[0].id
console.log('[COSE] 简书使用文集:', notebooks[0].name, 'ID:', notebookId)
// 创建新文章
const createResp = await fetch('https://www.jianshu.com/author/notes', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
notebook_id: String(notebookId),
title: content.title || '无标题',
at_bottom: false,
}),
})
const noteData = await createResp.json()
if (noteData && noteData.id) {
const noteId = noteData.id
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${noteId}`
console.log('[COSE] 简书创建文章成功,ID:', noteId)
tab = await chrome.tabs.create({ url: targetUrl, active: false })
await addTabToSyncGroup(tab.id, tab.windowId)
await waitForTab(tab.id)
} else {
console.error('[COSE] 简书创建文章失败:', noteData)
return { success: false, message: '简书创建文章失败,请确保已登录' }
}
} catch (e) {
console.error('[COSE] 简书 API 调用失败:', e)
return { success: false, message: '简书 API 调用失败: ' + e.message }
}
} else if (platformId === 'xiaohongshu') {
// 小红书:需要先点击"新的创作"按钮,等待编辑器加载后填充
console.log('[COSE] 开始处理小红书同步...')
@@ -3695,49 +3804,6 @@ function fillContentOnPage(content, platformId) {
document.head.appendChild(script)
script.remove()
}
// 简书
else if (host.includes('jianshu.com')) {
// 填充标题 - 简书使用 input._24i7u,需要使用 native setter
const titleInput = await waitFor('input._24i7u, input[class*="title"]')
if (titleInput) {
titleInput.focus()
const inputSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
inputSetter.call(titleInput, title)
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 简书标题填充成功')
} else {
console.log('[COSE] 简书未找到标题输入框')
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 500))
// 简书使用 textarea#arthur-editor 作为 Markdown 编辑器
const editor =
document.querySelector('#arthur-editor') || document.querySelector('textarea._3swFR')
if (editor) {
editor.focus()
const textareaSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
textareaSetter.call(editor, contentToFill)
editor.dispatchEvent(
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' })
)
editor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 简书内容填充成功')
} else {
console.log('[COSE] 简书未找到编辑器')
}
}
// 腾讯云开发者社区
else if (host.includes('cloud.tencent.com')) {
console.log('[COSE] TencentCloud 开始同步...')
+19 -4
View File
@@ -4,16 +4,24 @@ 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 {
inspectZhihuTask,
ZhihuPlatform,
syncZhihuContent,
} from './zhihu.js'
import { ToutiaoPlatform, syncToutiaoContent } from './toutiao.js'
import { SegmentFaultPlatform } from './segmentfault.js'
import { CnblogsPlatform } from './cnblogs.js'
import { OSChinaPlatform } from './oschina.js'
import { CTO51Platform } from './cto51.js'
import { InfoQPlatform } from './infoq.js'
import { JianshuPlatform } from './jianshu.js'
import {
inspectJianshuTask,
JianshuPlatform,
syncJianshuContent,
} from './jianshu.js'
import { BaijiahaoPlatform } from './baijiahao.js'
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
import { TencentCloudPlatform } from './tencentcloud.js'
@@ -112,11 +120,18 @@ function getPlatformFiller(hostname) {
const SYNC_HANDLERS = {
csdn: syncCSDNContent,
juejin: syncJuejinContent,
jianshu: syncJianshuContent,
wechat: syncWechatContent,
zhihu: syncZhihuContent,
toutiao: syncToutiaoContent,
wangyihao: syncWangyihaoContent,
}
const INSPECT_HANDLERS = {
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
jianshu: inspectJianshuTask,
zhihu: inspectZhihuTask,
}
// 导出
export { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
export { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
+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 }
+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 }
@@ -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,6 +27,7 @@ export const ZhihuLoginConfig = {
method: 'GET',
checkLogin: response => response?.id,
getUserInfo: response => ({
platformUid: response?.id,
username: response?.name,
avatar: response?.avatar_url,
}),
@@ -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 }
+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.6.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
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict'
import { 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 { 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,
)
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.');