Compare commits

...

4 Commits

12 changed files with 912 additions and 40 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.zip binary
+4 -2
View File
@@ -12,7 +12,9 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
## Browser Provider
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入 CSDN、掘金、简书、知乎、今日头条、百家号、网易号、搜狐号、InfoQ、腾讯云开发者社区、阿里云开发者社区、思否、51CTO开源中国:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。CSDN 只使用编辑器自带的签名请求客户端读取创作者资料,思否只从当前页面的 Next.js 会话数据读取 UID;网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID,51CTO 只接受个人空间页面中可验证的稳定 UID,开源中国只接受官方账号接口返回的稳定 UID。
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入 CSDN、掘金、简书、知乎、今日头条、百家号、网易号、搜狐号、InfoQ、腾讯云开发者社区、阿里云开发者社区、思否、51CTO开源中国和腾讯内容开放平台(企鹅号):扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。CSDN 只使用编辑器自带的签名请求客户端读取创作者资料,思否只从当前页面的 Next.js 会话数据读取 UID;网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID,51CTO 只接受个人空间页面中可验证的稳定 UID,开源中国只接受官方账号接口返回的稳定 UID,企鹅号只接受页面中的稳定 `userID` / `userId`
扩展支持服务端随发布任务下发经过签名安装、Schema 校验并固化版本身份的声明式 Browser Provider 快照。插件只能覆盖核心十五个平台的标题、正文与第一层草稿/发布按钮定义;平台 URL、账号检测和最终结果判断仍由扩展核心维护。扩展会再次严格校验平台、选择器、编辑器类型、内容格式、源提交和内容哈希,写入后回读标题、正文长度和图片数量。配置漂移或回读失败时保留编辑器并返回明确失败,不执行仓库代码,也不会把未知按钮当作核心动作继续点击。
Browser Provider 仅负责账号核对、编辑器填充和草稿/公开文章状态回读,不会自动点击最终公开发布按钮。所有已接入平台仍保留复制发布稿入口;未进入受控交接白名单的平台继续只支持手动复制,不会伪报“已同步”。
@@ -56,7 +58,7 @@ AiToEarn 自行维护网页桥接、载荷协议、产品界面、打包和发
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\package.ps1
```
脚本会校验 `manifest.json`、全部 JavaScript 语法必需文件,再输出:
脚本会校验 `manifest.json`、全部 JavaScript 语法必需文件、互动桥接、分发桥接和 Browser Agent Provider 快照执行链,再输出:
```text
dist/aitoearn-extension-v<version>.zip
+156 -27
View File
@@ -1,4 +1,9 @@
import { renderToutiaoMarkdown, renderToutiaoPlainText } from '../cose/core/platforms/toutiao.js'
import { AUTO_FILL_PLATFORM_IDS } from '../cose/core/platforms/index.js'
import {
getDeclarativeProviderAction,
validateBrowserProviderSnapshot,
} from '../cose/core/declarative-browser-provider.js'
const ALLOWED_API_ORIGINS = new Set([
'https://wxapi.frp.it1024.cc',
@@ -7,17 +12,24 @@ const ALLOWED_API_ORIGINS = new Set([
const BROWSER_AGENT_STORAGE_KEY = 'aitoearn_browser_agent_v1'
const BROWSER_AGENT_ALARM_NAME = 'aitoearn-browser-agent-poll'
const BROWSER_AGENT_PLATFORM_IDS = [
'csdn',
'tencentcloud',
'aliyun',
'qiehao',
]
const BROWSER_AGENT_PLATFORM_IDS = [...AUTO_FILL_PLATFORM_IDS]
.filter(platformId => platformId !== 'wechat')
const PLATFORM_TAB_PATTERNS = {
csdn: ['https://editor.csdn.net/md/*'],
juejin: ['https://juejin.cn/*'],
jianshu: ['https://www.jianshu.com/*'],
zhihu: ['https://zhuanlan.zhihu.com/*', 'https://www.zhihu.com/*'],
toutiao: ['https://mp.toutiao.com/*'],
baijiahao: ['https://baijiahao.baidu.com/*'],
wangyi: ['https://mp.163.com/*'],
sohu: ['https://mp.sohu.com/*'],
infoq: ['https://xie.infoq.cn/*'],
tencentcloud: ['https://cloud.tencent.com/developer/*'],
aliyun: ['https://developer.aliyun.com/*'],
segmentfault: ['https://segmentfault.com/*'],
'51cto': ['https://blog.51cto.com/*'],
oschina: ['https://my.oschina.net/*'],
qiehao: ['https://om.qq.com/*'],
}
@@ -53,10 +65,25 @@ function normalizeApiBaseUrl(value) {
return url.origin
}
function getPlatformAction(platformId, publishMode) {
function getPlatformAction(platformId, publishMode, providerConfig) {
if (providerConfig !== undefined) {
return getDeclarativeProviderAction(providerConfig, platformId, publishMode)
}
return PLATFORM_ACTIONS[platformId]?.[publishMode] || null
}
function getTaskExecutionPolicy(platformId, publishMode, openedStatus, providerConfig) {
if (openedStatus === 'published') return 'report_published'
const action = getPlatformAction(platformId, publishMode, providerConfig)
if (openedStatus === 'draft_saved' && (publishMode === 'draft' || !action)) {
return 'report_draft_saved'
}
if (action) return 'perform_action'
if (openedStatus === 'publication_uncertain') return 'report_uncertain'
return 'report_filled'
}
function getExtensionVersion() {
return chrome.runtime.getManifest().version
}
@@ -228,29 +255,68 @@ async function hasCurrentPlatformTab(platformId) {
return tabs.some(tab => tab.id && !tab.discarded)
}
async function getCurrentPlatformAccount(platformId) {
async function inspectCurrentPlatformAccount(platformId) {
const response = await chrome.runtime.sendMessage({
type: 'GET_PLATFORM_ACCOUNT',
platformId,
})
if (!response?.success || !response.platformUid) return null
if (!response?.success || !response.platformUid) {
return {
account: null,
error: response?.error
|| (response?.loggedIn
? '已检测到登录状态,但平台没有返回稳定账号标识'
: '平台登录状态未通过核对'),
}
}
return {
platform: platformId,
platformUid: String(response.platformUid),
displayName: response.displayName || String(response.platformUid),
account: {
platform: platformId,
platformUid: String(response.platformUid),
displayName: response.displayName || String(response.platformUid),
},
error: null,
}
}
async function listCurrentPlatformAccounts() {
async function getCurrentPlatformAccount(platformId) {
return (await inspectCurrentPlatformAccount(platformId)).account
}
async function inspectCurrentPlatformAccounts() {
const accounts = []
const checks = []
for (const platformId of BROWSER_AGENT_PLATFORM_IDS) {
if (!await hasCurrentPlatformTab(platformId)) continue
try {
const account = await getCurrentPlatformAccount(platformId)
if (account) accounts.push(account)
} catch {}
const result = await inspectCurrentPlatformAccount(platformId)
if (result.account) {
accounts.push(result.account)
checks.push({
platform: platformId,
status: 'verified',
message: '账号已核对',
})
} else {
checks.push({
platform: platformId,
status: 'failed',
message: result.error,
})
}
} catch (error) {
checks.push({
platform: platformId,
status: 'failed',
message: getMessage(error, '平台账号检测失败'),
})
}
}
return accounts
return { accounts, checks }
}
async function listCurrentPlatformAccounts() {
return (await inspectCurrentPlatformAccounts()).accounts
}
function toTaskContent(task) {
@@ -275,8 +341,8 @@ function createTaskResult(task, account, status, extra = {}) {
}
}
async function clickPlatformAction(platformId, tabId, publishMode) {
const action = getPlatformAction(platformId, publishMode)
async function clickPlatformAction(platformId, tabId, publishMode, providerConfig) {
const action = getPlatformAction(platformId, publishMode, providerConfig)
if (!action) {
return {
success: false,
@@ -340,6 +406,20 @@ async function executeClaimedTask(task) {
}
}
let providerConfig
try {
providerConfig = task.handoff?.providerConfig === undefined
? undefined
: validateBrowserProviderSnapshot(task.handoff.providerConfig, task.platform)
} catch (error) {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
message: getMessage(error, '声明式 Browser Provider 配置无效'),
}),
}
}
let opened
try {
opened = await chrome.runtime.sendMessage({
@@ -347,6 +427,7 @@ async function executeClaimedTask(task) {
platformId: task.platform,
content: toTaskContent(task),
expectedPlatformUid: task.platformUid,
providerConfig,
})
} catch (error) {
return {
@@ -365,7 +446,41 @@ async function executeClaimedTask(task) {
}
}
const clickResult = await clickPlatformAction(task.platform, opened.tabId, task.handoff.publishMode)
const executionPolicy = getTaskExecutionPolicy(
task.platform,
task.handoff.publishMode,
opened.status,
providerConfig,
)
if (executionPolicy !== 'perform_action') {
const status = {
report_published: 'published',
report_draft_saved: 'draft_saved',
report_uncertain: 'publication_uncertain',
report_filled: 'filled',
}[executionPolicy]
const fallbackMessage = status === 'filled'
? task.handoff.publishMode === 'publish'
? '内容已自动填充,当前平台发布动作尚未完成受控验证,请在已打开的编辑器中人工确认发布'
: '内容已自动填充,当前平台草稿动作尚未完成受控验证,请在已打开的编辑器中人工保存草稿'
: undefined
return {
report: true,
result: createTaskResult(task, account, status, {
tabId: opened.tabId,
url: opened.url,
message: opened.message || fallbackMessage,
}),
}
}
const clickResult = await clickPlatformAction(
task.platform,
opened.tabId,
task.handoff.publishMode,
providerConfig,
)
if (!clickResult.success) {
return {
report: true,
@@ -448,15 +563,18 @@ async function pollBrowserAgentNow() {
const agent = await getStoredBrowserAgent()
if (!agent) return { success: false, status: 'not_paired' }
const accounts = await listCurrentPlatformAccounts()
const { accounts, checks: accountChecks } = await inspectCurrentPlatformAccounts()
if (accounts.length === 0) {
const next = await updateRuntimeState(agent, {
status: 'waiting_for_platform_account',
message: '未找到已打开且可核对的受支持平台账号',
message: accountChecks.length > 0
? `已打开 ${accountChecks.length} 个受支持平台,但账号核对未通过`
: '未找到已打开的受支持平台标签页',
lastError: undefined,
accounts: [],
accountChecks,
})
return toPublicAgentState(next, { success: true, accounts: [] })
return toPublicAgentState(next, { success: true, accounts: [], accountChecks })
}
let claimed
@@ -476,8 +594,9 @@ async function pollBrowserAgentNow() {
status: 'offline',
lastError: getMessage(error, '领取浏览器任务失败'),
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: false, accounts })
return toPublicAgentState(next, { success: false, accounts, accountChecks })
}
const task = claimed?.task
@@ -488,8 +607,9 @@ async function pollBrowserAgentNow() {
lastError: undefined,
lastClaimAt: new Date().toISOString(),
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: true, accounts, task: null })
return toPublicAgentState(next, { success: true, accounts, accountChecks, task: null })
}
const execution = await executeClaimedTask(task)
@@ -499,8 +619,9 @@ async function pollBrowserAgentNow() {
message: execution.message,
lastTaskId: task.taskId,
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: false, accounts, taskId: task.taskId })
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
}
try {
@@ -515,10 +636,12 @@ async function pollBrowserAgentNow() {
lastTaskId: task.taskId,
lastResultAt: new Date().toISOString(),
accounts,
accountChecks,
})
return toPublicAgentState(next, {
success: true,
accounts,
accountChecks,
taskId: task.taskId,
result: execution.result,
})
@@ -529,8 +652,9 @@ async function pollBrowserAgentNow() {
lastError: getMessage(error, '浏览器任务结果回写失败'),
lastTaskId: task.taskId,
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: false, accounts, taskId: task.taskId })
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
}
})().finally(() => {
pollingPromise = null
@@ -593,7 +717,12 @@ if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage && chrome.alarms)
export {
ALLOWED_API_ORIGINS,
BROWSER_AGENT_PLATFORM_IDS,
PLATFORM_TAB_PATTERNS,
executeClaimedTask,
getPlatformAction,
getTaskExecutionPolicy,
inspectCurrentPlatformAccounts,
listCurrentPlatformAccounts,
normalizeApiBaseUrl,
toTaskContent,
}
+57 -2
View File
@@ -6,6 +6,10 @@ import {
SYNC_HANDLERS,
} from './core/platforms/index.js'
import { canReturnExistingPlatformTaskState } from './core/task-state.js'
import {
syncDeclarativeBrowserProvider,
validateBrowserProviderSnapshot,
} from './core/declarative-browser-provider.js'
import { qianfanIntercept } from './core/platforms/qianfan.js'
import { detectSupportedPlatformUser } from './detection/src/supported-platforms.js'
// [DISABLED] import { fillAlipayOpenContent } from '@cose/core/src/platforms/alipayopen.js'
@@ -263,6 +267,21 @@ async function getCsdnProfileFromSiteContext() {
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async () => {
const readVisibleProfile = () => {
const image = document.querySelector(
'a[href*="mp.csdn.net/mp_blog/manage/article"] img[alt]',
)
const userName = image?.getAttribute('alt')?.trim()
if (!userName) return null
return {
userName,
nickname: userName,
avatarUrl: image.currentSrc || image.src || '',
}
}
const visibleProfile = readVisibleProfile()
const deadline = Date.now() + 12000
while (Date.now() < deadline) {
if (typeof window.csdn?.httpGateway === 'function') break
@@ -270,6 +289,9 @@ async function getCsdnProfileFromSiteContext() {
}
if (typeof window.csdn?.httpGateway !== 'function') {
if (visibleProfile) {
return { status: 200, body: { data: visibleProfile }, source: 'editor_dom' }
}
return { status: 0, error: 'CSDN 编辑器签名请求客户端未加载' }
}
@@ -280,6 +302,14 @@ async function getCsdnProfileFromSiteContext() {
)
return { status: 200, body }
} catch (error) {
if (visibleProfile) {
return {
status: 200,
body: { data: visibleProfile },
source: 'editor_dom',
apiError: error instanceof Error ? error.message : 'CSDN 账号资料请求失败',
}
}
const response = error?.response
return {
status: response?.status || 0,
@@ -543,7 +573,8 @@ async function handleMessage(request, sender) {
request.platformId,
request.content,
request.expectedPlatformUid,
request.tabId
request.tabId,
request.providerConfig
)
case 'INSPECT_PLATFORM_TASK':
return await inspectPlatformTask(
@@ -744,7 +775,7 @@ function validateExpectedPlatformAccount(account, expectedPlatformUid) {
return null
}
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId) {
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId, providerConfig) {
if (!content || typeof content !== 'object') {
return { success: false, error: '缺少待填充内容' }
}
@@ -765,6 +796,15 @@ async function openPlatformTask(platformId, content, expectedPlatformUid, existi
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
if (mismatch) return mismatch
let validatedProviderConfig
try {
validatedProviderConfig = providerConfig === undefined
? undefined
: validateBrowserProviderSnapshot(providerConfig, platformId)
} catch (error) {
return { success: false, error: error.message || '声明式 Browser Provider 配置无效' }
}
const inspect = INSPECT_HANDLERS[platformId]
let reusableTabId
if (inspect && Number.isInteger(existingTabId) && existingTabId > 0) {
@@ -790,6 +830,7 @@ async function openPlatformTask(platformId, content, expectedPlatformUid, existi
const result = await syncToPlatform(platformId, content, {
active: true,
tabId: reusableTabId,
providerConfig: validatedProviderConfig,
})
if (!result?.success) return result
if (result.tabId) {
@@ -929,6 +970,20 @@ async function syncToPlatform(platformId, content, options = {}) {
try {
let tab
if (options.providerConfig) {
return await syncDeclarativeBrowserProvider(
platform,
content,
options.providerConfig,
options,
{
chrome,
waitForTab,
addTabToSyncGroup,
},
)
}
// 检查是否有平台特定的同步处理器
const syncHandler = SYNC_HANDLERS[platformId]
if (syncHandler) {
@@ -0,0 +1,365 @@
const CONTROLLED_BROWSER_PROVIDER_PLATFORM_IDS = new Set([
'csdn',
'juejin',
'jianshu',
'zhihu',
'toutiao',
'baijiahao',
'wangyi',
'sohu',
'infoq',
'tencentcloud',
'aliyun',
'segmentfault',
'51cto',
'oschina',
'qiehao',
])
const EDITOR_TYPES = new Set(['textarea', 'contenteditable', 'codemirror5'])
const CONTENT_FORMATS = new Set(['markdown', 'html', 'plainText'])
function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function assertExactKeys(value, allowedKeys, label) {
const unexpected = Object.keys(value).filter(key => !allowedKeys.includes(key))
if (unexpected.length > 0) {
throw new Error(`${label}包含不允许的字段:${unexpected.join(', ')}`)
}
}
function assertString(value, label, maximumLength, pattern) {
if (typeof value !== 'string' || !value.trim() || value.length > maximumLength) {
throw new Error(`${label}无效`)
}
if (pattern && !pattern.test(value)) throw new Error(`${label}格式无效`)
return value.trim()
}
function validateSelector(value, label) {
return assertString(value, label, 500, /^[^\u0000-\u001f\u007f]+$/)
}
function validateAction(value, label) {
if (value === undefined) return undefined
if (!isRecord(value)) throw new Error(`${label}无效`)
assertExactKeys(value, ['selector', 'exactText'], label)
return {
selector: validateSelector(value.selector, `${label}选择器`),
exactText: assertString(value.exactText, `${label}按钮文字`, 100),
}
}
function validateBrowserProviderSnapshot(value, expectedPlatformId) {
if (value === undefined) return null
if (!isRecord(value)) throw new Error('声明式 Browser Provider 快照无效')
assertExactKeys(
value,
['pluginId', 'version', 'sourceCommit', 'contentHash', 'definition'],
'声明式 Browser Provider 快照',
)
const pluginId = assertString(value.pluginId, 'Provider 插件 ID', 100, /^[a-z\d][\w.-]*$/i)
const version = assertString(value.version, 'Provider 插件版本', 64)
const sourceCommit = assertString(value.sourceCommit, 'Provider 源提交', 40, /^[0-9a-f]{40}$/i)
const contentHash = assertString(value.contentHash, 'Provider 内容哈希', 64, /^[0-9a-f]{64}$/i)
const definition = value.definition
if (!isRecord(definition)) throw new Error('声明式 Browser Provider 定义无效')
assertExactKeys(
definition,
['platform', 'title', 'content', 'draftAction', 'publishAction'],
'声明式 Browser Provider 定义',
)
const platform = assertString(definition.platform, 'Provider 平台', 32)
if (!CONTROLLED_BROWSER_PROVIDER_PLATFORM_IDS.has(platform) || platform !== expectedPlatformId) {
throw new Error('声明式 Browser Provider 平台与任务平台不一致')
}
if (!isRecord(definition.title)) throw new Error('Provider 标题字段无效')
assertExactKeys(definition.title, ['selector', 'activationSelector'], 'Provider 标题字段')
const title = {
selector: validateSelector(definition.title.selector, 'Provider 标题选择器'),
}
if (definition.title.activationSelector !== undefined) {
title.activationSelector = validateSelector(
definition.title.activationSelector,
'Provider 标题激活选择器',
)
}
if (!isRecord(definition.content)) throw new Error('Provider 正文字段无效')
assertExactKeys(definition.content, ['selector', 'editorType', 'format'], 'Provider 正文字段')
const editorType = assertString(definition.content.editorType, 'Provider 编辑器类型', 32)
const format = assertString(definition.content.format, 'Provider 内容格式', 32)
if (!EDITOR_TYPES.has(editorType)) throw new Error('Provider 编辑器类型不受支持')
if (!CONTENT_FORMATS.has(format)) throw new Error('Provider 内容格式不受支持')
const normalizedDefinition = {
platform,
title,
content: {
selector: validateSelector(definition.content.selector, 'Provider 正文选择器'),
editorType,
format,
},
}
const draftAction = validateAction(definition.draftAction, 'Provider 草稿动作')
const publishAction = validateAction(definition.publishAction, 'Provider 发布动作')
if (draftAction) normalizedDefinition.draftAction = draftAction
if (publishAction) normalizedDefinition.publishAction = publishAction
return {
pluginId,
version,
sourceCommit,
contentHash,
definition: normalizedDefinition,
}
}
function getDeclarativeProviderAction(providerConfig, platformId, publishMode) {
const validated = validateBrowserProviderSnapshot(providerConfig, platformId)
if (!validated) return null
const action = publishMode === 'draft'
? validated.definition.draftAction
: publishMode === 'publish'
? validated.definition.publishAction
: undefined
if (!action) return null
return { selector: action.selector, text: action.exactText }
}
async function fillDeclarativeBrowserProvider(definition, content) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
function findElement(selector) {
const candidates = Array.from(document.querySelectorAll(selector))
return candidates.find((element) => {
const style = window.getComputedStyle?.(element)
return !style || (style.display !== 'none' && style.visibility !== 'hidden')
}) || null
}
async function waitForElement(selector, timeout = 15000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
const element = findElement(selector)
if (element) return element
await sleep(100)
}
return findElement(selector)
}
function dispatchValueEvents(element, value, inputType) {
try {
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType,
}))
} catch {
element.dispatchEvent(new Event('input', { bubbles: true }))
}
element.dispatchEvent(new Event('change', { bubbles: true }))
element.dispatchEvent(new Event('blur', { bubbles: true }))
}
function setNativeValue(element, value) {
element.focus()
const prototype = element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(element, value)
else element.value = value
dispatchValueEvents(element, value, 'insertText')
}
function selectContents(element) {
const range = document.createRange()
range.selectNodeContents(element)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
function setContentEditableValue(element, value, format, plainText) {
element.focus()
selectContents(element)
const command = format === 'html' ? 'insertHTML' : 'insertText'
const inserted = document.execCommand?.(command, false, value)
if (!inserted) {
if (format === 'html') element.innerHTML = value
else element.textContent = value
}
dispatchValueEvents(element, plainText || value, format === 'html' ? 'insertFromPaste' : 'insertText')
}
function getCodeMirror(element) {
return element?.CodeMirror || element?.closest?.('.CodeMirror')?.CodeMirror
}
const title = typeof content.title === 'string' ? content.title : ''
if (definition.title.activationSelector) {
const activator = await waitForElement(definition.title.activationSelector)
if (!activator) return { success: false, error: '未找到 Provider 标题激活控件' }
activator.click()
await sleep(150)
}
const titleElement = await waitForElement(definition.title.selector)
if (!titleElement) return { success: false, error: '未找到 Provider 标题输入框' }
if (title) {
if (titleElement instanceof HTMLInputElement || titleElement instanceof HTMLTextAreaElement) {
setNativeValue(titleElement, title)
} else if (titleElement.isContentEditable || titleElement.getAttribute('contenteditable') === 'true') {
setContentEditableValue(titleElement, title, 'plainText', title)
} else {
return { success: false, error: 'Provider 标题选择器没有指向可写控件' }
}
await sleep(200)
const actualTitle = 'value' in titleElement ? titleElement.value : titleElement.textContent
if (normalizeText(actualTitle) !== normalizeText(title)) {
return { success: false, error: '平台未确认接收 Provider 标题' }
}
}
const contentValue = typeof content[definition.content.format] === 'string'
? content[definition.content.format]
: typeof content.body === 'string'
? content.body
: ''
if (!contentValue) return { success: true, method: 'title-only', textLength: 0, imageCount: 0 }
const contentElement = await waitForElement(definition.content.selector)
if (!contentElement) return { success: false, error: '未找到 Provider 正文编辑器' }
let actualContent = ''
if (definition.content.editorType === 'codemirror5') {
const editor = getCodeMirror(contentElement)
if (!editor?.setValue || !editor?.getValue) {
return { success: false, error: 'Provider 正文选择器没有指向 CodeMirror 5 编辑器' }
}
editor.setValue(contentValue)
editor.focus?.()
actualContent = editor.getValue()
} else if (definition.content.editorType === 'textarea') {
if (!(contentElement instanceof HTMLTextAreaElement || contentElement instanceof HTMLInputElement)) {
return { success: false, error: 'Provider 正文选择器没有指向文本输入控件' }
}
setNativeValue(contentElement, contentValue)
actualContent = contentElement.value
} else {
if (!contentElement.isContentEditable && contentElement.getAttribute('contenteditable') !== 'true') {
return { success: false, error: 'Provider 正文选择器没有指向富文本编辑器' }
}
setContentEditableValue(
contentElement,
contentValue,
definition.content.format,
content.plainText,
)
actualContent = contentElement.innerText || contentElement.textContent || ''
}
await sleep(500)
if (definition.content.editorType === 'codemirror5') {
actualContent = getCodeMirror(contentElement)?.getValue?.() || ''
} else if (definition.content.editorType === 'textarea') {
actualContent = contentElement.value || ''
} else {
actualContent = contentElement.innerText || contentElement.textContent || ''
}
const expectedText = definition.content.editorType === 'contenteditable'
? content.plainText || contentValue.replace(/<[^>]+>/g, ' ')
: contentValue
const normalizedExpected = normalizeText(expectedText)
const normalizedActual = normalizeText(actualContent)
const minimumLength = Math.min(
normalizedExpected.length,
Math.min(Math.max(12, Math.floor(normalizedExpected.length * 0.1)), 120),
)
const expectedPrefix = normalizedExpected.slice(0, Math.min(32, normalizedExpected.length))
const expectedImageCount = definition.content.format === 'html'
? (contentValue.match(/<img\b/gi) || []).length
: 0
const actualImageCount = contentElement.querySelectorAll?.('img').length || 0
if (
normalizedActual.length < minimumLength
|| (expectedPrefix.length >= 8 && !normalizedActual.includes(expectedPrefix))
|| actualImageCount < expectedImageCount
) {
return { success: false, error: '平台未确认接收 Provider 正文,请保留编辑器并人工核对' }
}
return {
success: true,
method: definition.content.editorType,
textLength: normalizedActual.length,
imageCount: actualImageCount,
}
}
async function syncDeclarativeBrowserProvider(platform, content, providerConfig, options, helpers) {
const validated = validateBrowserProviderSnapshot(providerConfig, platform.id)
let tab
if (Number.isInteger(options.tabId) && options.tabId > 0) {
try {
tab = await helpers.chrome.tabs.update(options.tabId, {
url: platform.publishUrl,
active: options.active === true,
})
} catch {}
}
if (!tab) {
tab = await helpers.chrome.tabs.create({
url: platform.publishUrl,
active: options.active === true,
})
await helpers.addTabToSyncGroup(tab.id, tab.windowId)
}
await helpers.waitForTab(tab.id)
const execution = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillDeclarativeBrowserProvider,
args: [validated.definition, content],
world: 'MAIN',
})
const result = execution?.[0]?.result
if (!result?.success) {
return {
success: false,
message: result?.error || '声明式 Browser Provider 填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: `已使用 ${validated.pluginId}@${validated.version} 填充平台编辑器`,
tabId: tab.id,
provider: {
pluginId: validated.pluginId,
version: validated.version,
contentHash: validated.contentHash,
},
metrics: {
textLength: result.textLength || 0,
imageCount: result.imageCount || 0,
},
}
}
export {
CONTROLLED_BROWSER_PROVIDER_PLATFORM_IDS,
fillDeclarativeBrowserProvider,
getDeclarativeProviderAction,
syncDeclarativeBrowserProvider,
validateBrowserProviderSnapshot,
}
@@ -13,7 +13,9 @@ function normalizeString(value) {
function parseAliyunAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
const account = data?.user && typeof data.user === 'object' ? data.user : data
const platformUid = normalizeString(account?.userId)
const platformUid = normalizeString(account?.uccId)
|| normalizeString(account?.ucc_id)
|| normalizeString(account?.userId)
|| normalizeString(account?.user_id)
|| normalizeString(account?.uid)
|| normalizeString(account?.aliuid)
@@ -25,9 +25,9 @@ function parseQiehaoAccount(html) {
}
return {
platformUid: readField(html, ['userID', 'userId']),
username: readField(html, ['nickName', 'nickname', 'userName', 'name']) || '',
avatar: readField(html, ['avatarUrl', 'avatar']) || '',
platformUid: readField(html, ['mediaId', 'userID', 'userId']),
username: readField(html, ['mediaName', 'nickName', 'nickname', 'userName', 'name']) || '',
avatar: readField(html, ['header', 'avatarUrl', 'avatar']) || '',
}
}
@@ -8,7 +8,7 @@ function normalizeString(value) {
function getCurrentAccountScope(html) {
const match = html.match(
/"(?:currentUser|userInfo|creatorInfo)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,4000}\})/,
/"(?:session|currentUser|userInfo|creatorInfo)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,12000}\})/,
)
return match?.[1] || ''
}
@@ -29,7 +29,8 @@ function parseTencentCloudAccount(html) {
}
return {
platformUid: readField('uin')
platformUid: readField('loginUid')
|| readField('uin')
|| readField('userId')
|| readField('user_id')
|| readField('uid')
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.18.2",
"version": "1.19.2",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
"permissions": [
"activeTab",
+66 -1
View File
@@ -60,6 +60,11 @@ if ($LASTEXITCODE -ne 0) {
throw 'Distribution bridge validation failed'
}
& node (Join-Path $PSScriptRoot 'test-browser-agent.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Browser Agent 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 +94,67 @@ foreach ($relativePath in $requiredPaths) {
}
}
Compress-Archive -Path (Join-Path $stageRoot '*') -DestinationPath $archivePath -CompressionLevel Optimal
Add-Type -AssemblyName System.IO.Compression
$archiveStream = $null
$archive = $null
try {
$archiveStream = [System.IO.File]::Open(
$archivePath,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None
)
$archive = [System.IO.Compression.ZipArchive]::new(
$archiveStream,
[System.IO.Compression.ZipArchiveMode]::Create,
$false,
[System.Text.Encoding]::UTF8
)
$fixedTimestamp = [System.DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [System.TimeSpan]::Zero)
$stageRootFullPath = [System.IO.Path]::GetFullPath($stageRoot)
$archiveFiles = Get-ChildItem -LiteralPath $stageRoot -Recurse -File |
ForEach-Object {
[pscustomobject]@{
EntryName = $_.FullName.Substring($stageRootFullPath.Length + 1).Replace('\', '/')
SourcePath = $_.FullName
}
} |
Sort-Object -Property EntryName
foreach ($archiveFile in $archiveFiles) {
$entry = $archive.CreateEntry(
$archiveFile.EntryName,
[System.IO.Compression.CompressionLevel]::Optimal
)
$entry.LastWriteTime = $fixedTimestamp
$entry.ExternalAttributes = 0
$sourceStream = $null
$entryStream = $null
try {
$sourceStream = [System.IO.File]::OpenRead($archiveFile.SourcePath)
$entryStream = $entry.Open()
$sourceStream.CopyTo($entryStream)
}
finally {
if ($null -ne $entryStream) {
$entryStream.Dispose()
}
if ($null -ne $sourceStream) {
$sourceStream.Dispose()
}
}
}
}
finally {
if ($null -ne $archive) {
$archive.Dispose()
}
if ($null -ne $archiveStream) {
$archiveStream.Dispose()
}
}
$verificationRoot = Join-Path $distRoot '.verify'
if (Test-Path -LiteralPath $verificationRoot) {
+209 -1
View File
@@ -3,10 +3,19 @@ import { readFile } from 'node:fs/promises'
import {
ALLOWED_API_ORIGINS,
BROWSER_AGENT_PLATFORM_IDS,
PLATFORM_TAB_PATTERNS,
executeClaimedTask,
getPlatformAction,
getTaskExecutionPolicy,
inspectCurrentPlatformAccounts,
listCurrentPlatformAccounts,
normalizeApiBaseUrl,
toTaskContent,
} from '../distribution/browser-agent/index.js'
import {
getDeclarativeProviderAction,
validateBrowserProviderSnapshot,
} from '../distribution/cose/core/declarative-browser-provider.js'
assert.deepEqual(
ALLOWED_API_ORIGINS,
@@ -25,7 +34,27 @@ assert.throws(
() => normalizeApiBaseUrl('https://wxapi.frp.it1024.cc/v2'),
/已配置的 AiToEarn API 地址/,
)
assert.deepEqual(BROWSER_AGENT_PLATFORM_IDS, ['csdn', 'tencentcloud', 'aliyun', 'qiehao'])
assert.deepEqual(BROWSER_AGENT_PLATFORM_IDS, [
'csdn',
'juejin',
'jianshu',
'zhihu',
'toutiao',
'baijiahao',
'wangyi',
'sohu',
'infoq',
'tencentcloud',
'aliyun',
'segmentfault',
'51cto',
'oschina',
'qiehao',
])
assert.equal(
BROWSER_AGENT_PLATFORM_IDS.every(platformId => PLATFORM_TAB_PATTERNS[platformId]?.length > 0),
true,
)
assert.deepEqual(getPlatformAction('qiehao', 'draft'), {
selector: 'button.omui-button.omui-button--background-grey',
text: '存草稿',
@@ -36,6 +65,185 @@ assert.deepEqual(getPlatformAction('qiehao', 'publish'), {
})
assert.equal(getPlatformAction('qiehao', 'unknown'), null)
const qiehaoProviderSnapshot = {
pluginId: 'browser-providers',
version: '1.0.0',
sourceCommit: 'a'.repeat(40),
contentHash: 'b'.repeat(64),
definition: {
platform: 'qiehao',
title: { selector: '.title' },
content: {
selector: '.ProseMirror',
editorType: 'contenteditable',
format: 'html',
},
draftAction: { selector: 'button.plugin-draft', exactText: '保存草稿' },
},
}
assert.deepEqual(getDeclarativeProviderAction(qiehaoProviderSnapshot, 'qiehao', 'draft'), {
selector: 'button.plugin-draft',
text: '保存草稿',
})
assert.deepEqual(getPlatformAction('qiehao', 'draft', qiehaoProviderSnapshot), {
selector: 'button.plugin-draft',
text: '保存草稿',
})
assert.throws(
() => validateBrowserProviderSnapshot({
...qiehaoProviderSnapshot,
definition: {
...qiehaoProviderSnapshot.definition,
editorUrl: 'https://attacker.example/editor',
},
}, 'qiehao'),
/不允许的字段/,
)
assert.throws(
() => validateBrowserProviderSnapshot(qiehaoProviderSnapshot, 'csdn'),
/任务平台不一致/,
)
assert.equal(getTaskExecutionPolicy('qiehao', 'draft', 'draft_saved'), 'report_draft_saved')
assert.equal(getTaskExecutionPolicy('qiehao', 'draft', 'filled'), 'perform_action')
assert.equal(getTaskExecutionPolicy('qiehao', 'publish', 'draft_saved'), 'perform_action')
assert.equal(getTaskExecutionPolicy('juejin', 'draft', 'draft_saved'), 'report_draft_saved')
assert.equal(getTaskExecutionPolicy('juejin', 'draft', 'filled'), 'report_filled')
assert.equal(getTaskExecutionPolicy('juejin', 'publish', 'draft_saved'), 'report_draft_saved')
assert.equal(getTaskExecutionPolicy('juejin', 'publish', 'publication_uncertain'), 'report_uncertain')
assert.equal(getTaskExecutionPolicy('juejin', 'publish', 'published'), 'report_published')
const sentMessages = []
let scriptExecutionCount = 0
globalThis.chrome = {
runtime: {
getManifest: () => ({ version: '1.18.3' }),
sendMessage: async (message) => {
sentMessages.push(message)
if (message.type === 'GET_PLATFORM_ACCOUNT') {
return {
success: true,
platformUid: `${message.platformId}-uid`,
displayName: `${message.platformId} account`,
}
}
if (message.type === 'OPEN_PLATFORM_TASK') {
return {
success: true,
status: 'filled',
tabId: 101,
url: 'https://juejin.cn/editor/drafts/new',
message: '掘金编辑器已填充',
}
}
throw new Error(`Unexpected message: ${message.type}`)
},
},
tabs: {
query: async ({ url }) => url?.length ? [{ id: 1, discarded: false }] : [],
},
scripting: {
executeScript: async () => {
scriptExecutionCount += 1
return []
},
},
}
const listedAccounts = await listCurrentPlatformAccounts()
assert.equal(listedAccounts.length, BROWSER_AGENT_PLATFORM_IDS.length)
assert.deepEqual(
listedAccounts.map(account => account.platform),
BROWSER_AGENT_PLATFORM_IDS,
)
const originalAccountMessageHandler = globalThis.chrome.runtime.sendMessage
const originalTabQueryHandler = globalThis.chrome.tabs.query
globalThis.chrome.tabs.query = async ({ url }) => {
const patterns = Array.isArray(url) ? url : []
return patterns.some(pattern => pattern.includes('csdn') || pattern.includes('aliyun'))
? [{ id: 1, discarded: false }]
: []
}
globalThis.chrome.runtime.sendMessage = async (message) => {
if (message.type !== 'GET_PLATFORM_ACCOUNT') {
throw new Error(`Unexpected message: ${message.type}`)
}
if (message.platformId === 'csdn') {
return {
success: true,
platformUid: 'csdn-uid',
displayName: 'CSDN account',
}
}
return {
success: false,
loggedIn: true,
error: '已检测到登录状态,但平台没有返回稳定账号 UID',
}
}
const inspectedAccounts = await inspectCurrentPlatformAccounts()
assert.deepEqual(inspectedAccounts.accounts, [{
platform: 'csdn',
platformUid: 'csdn-uid',
displayName: 'CSDN account',
}])
assert.deepEqual(inspectedAccounts.checks, [
{
platform: 'csdn',
status: 'verified',
message: '账号已核对',
},
{
platform: 'aliyun',
status: 'failed',
message: '已检测到登录状态,但平台没有返回稳定账号 UID',
},
])
globalThis.chrome.runtime.sendMessage = originalAccountMessageHandler
globalThis.chrome.tabs.query = originalTabQueryHandler
sentMessages.length = 0
const juejinProviderSnapshot = {
...qiehaoProviderSnapshot,
definition: {
platform: 'juejin',
title: { selector: '.title-input' },
content: {
selector: '.CodeMirror',
editorType: 'codemirror5',
format: 'markdown',
},
},
}
const filledExecution = await executeClaimedTask({
platform: 'juejin',
platformUid: 'juejin-uid',
title: '后台交接测试',
body: '# 正文',
handoff: {
publishMode: 'draft',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
providerConfig: juejinProviderSnapshot,
},
})
assert.equal(filledExecution.report, true)
assert.equal(filledExecution.result.status, 'filled')
assert.equal(filledExecution.result.platformUid, 'juejin-uid')
assert.equal(filledExecution.result.tabId, 101)
assert.equal(scriptExecutionCount, 0)
assert.deepEqual(sentMessages.map(message => message.type), [
'GET_PLATFORM_ACCOUNT',
'OPEN_PLATFORM_TASK',
])
assert.deepEqual(
sentMessages.find(message => message.type === 'OPEN_PLATFORM_TASK')?.providerConfig,
juejinProviderSnapshot,
)
delete globalThis.chrome
const markdownTaskContent = toTaskContent({
title: '浏览器交接测试',
body: '# 标题\n\n- 列表项\n\n![配图](https://example.com/image.png)',
+42
View File
@@ -489,6 +489,21 @@ assert.deepEqual(
avatar: 'https://example.com/aliyun-avatar.jpg',
},
)
assert.deepEqual(
parseAliyunAccount({
success: true,
data: {
uccId: 'aliyun-ucc-123456',
nickname: '阿里云当前作者',
avatar: 'https://example.com/aliyun-current-avatar.jpg',
},
}),
{
platformUid: 'aliyun-ucc-123456',
username: '阿里云当前作者',
avatar: 'https://example.com/aliyun-current-avatar.jpg',
},
)
assert.equal(
parseAliyunAccount({ success: true, data: { nickname: '无稳定标识' } }).platformUid,
undefined,
@@ -647,6 +662,16 @@ assert.deepEqual(
avatar: 'https://example.com/tencent-avatar.jpg',
},
)
assert.deepEqual(
parseTencentCloudAccount(
'<script>{"session":{"isLogined":true,"loginUid":7677118,"userInfo":{"nickname":"用户7677118","avatarUrl":"https://example.com/tencent-current-avatar.jpg"}}}</script>',
),
{
platformUid: '7677118',
username: '用户7677118',
avatar: 'https://example.com/tencent-current-avatar.jpg',
},
)
assert.equal(
parseTencentCloudAccount('<script>{"creatorInfo":{"nickname":"无稳定标识"}}</script>').platformUid,
undefined,
@@ -709,6 +734,16 @@ assert.deepEqual(
avatar: 'https://example.com/qiehao-avatar.jpg',
},
)
assert.deepEqual(
parseQiehaoAccount(
'<script>{"topInfo":{"mediaId":24973524,"mediaName":"企鹅号创作者973524","header":"https://example.com/qiehao-current-avatar.jpg"}}</script>',
),
{
platformUid: '24973524',
username: '企鹅号创作者973524',
avatar: 'https://example.com/qiehao-current-avatar.jpg',
},
)
assert.equal(
parseQiehaoAccount('<script>{"nickName":"没有稳定标识"}</script>').platformUid,
undefined,
@@ -902,6 +937,13 @@ const csdnDetectorSource = await readFile(
)
assert.doesNotMatch(csdnDetectorSource, /chrome\.cookies|Cookie\s*:/)
const coseBackgroundSource = await readFile(
new URL('../distribution/cose/background.js', import.meta.url),
'utf8',
)
assert.match(coseBackgroundSource, /mp\.csdn\.net\/mp_blog\/manage\/article/)
assert.match(coseBackgroundSource, /source: 'editor_dom'/)
const segmentFaultDetectorSource = await readFile(
new URL('../distribution/cose/detection/src/platforms/segmentfault.js', import.meta.url),
'utf8',