Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef64467f6b | |||
| c2d2a6c749 | |||
| 537df968a0 | |||
| dd9e628ac2 | |||
| 679e949703 | |||
| 984d646e1d | |||
| 52e8f7f4a0 | |||
| da8ad24f14 | |||
| 6561378bc1 | |||
| be650f6fab | |||
| 8a8dcb0023 | |||
| 874455e5f2 | |||
| ae62ff02b6 | |||
| 5821c5711b |
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.zip binary
|
||||
@@ -12,13 +12,15 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
|
||||
|
||||
## Browser Provider
|
||||
|
||||
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入 CSDN、掘金、简书、知乎、今日头条、百家号、网易号、搜狐号、InfoQ、腾讯云开发者社区和思否:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。CSDN 只使用编辑器自带的签名请求客户端读取创作者资料,思否只从当前页面的 Next.js 会话数据读取 UID;网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 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`。
|
||||
|
||||
Browser Provider 仅负责账号核对、编辑器填充和草稿/公开文章状态回读,不会自动点击最终公开发布按钮。CSDN 和思否仍保留复制发布稿入口;阿里云开发者社区及其他未通过独立验证的平台继续只支持手动复制,不会伪报“已同步”。
|
||||
扩展支持服务端随发布任务下发经过签名安装、Schema 校验并固化版本身份的声明式 Browser Provider 快照。插件只能覆盖核心十五个平台的标题、正文与第一层草稿/发布按钮定义;平台 URL、账号检测和最终结果判断仍由扩展核心维护。扩展会再次严格校验平台、选择器、编辑器类型、内容格式、源提交和内容哈希,写入后回读标题、正文长度和图片数量。配置漂移或回读失败时保留编辑器并返回明确失败,不执行仓库代码,也不会把未知按钮当作核心动作继续点击。
|
||||
|
||||
Browser Provider 仅负责账号核对、编辑器填充和草稿/公开文章状态回读,不会自动点击最终公开发布按钮。所有已接入平台仍保留复制发布稿入口;未进入受控交接白名单的平台继续只支持手动复制,不会伪报“已同步”。
|
||||
|
||||
扩展弹窗还保留公众号的单次人工确认填充入口,但不把它伪装成 Browser Provider。其他旧 COSE 选择器不会出现在扩展平台列表中,也不能通过桥接接口绕过能力白名单;仅有选择器或静态平台配置不代表平台已经接入。
|
||||
|
||||
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。CSDN 只有 `blog.csdn.net/:author/article/details/:id`、掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id` 或 `www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id` 或 `www.infoq.cn/article/:id`、腾讯云只有 `cloud.tencent.com/developer/article/:id`、思否只有 `segmentfault.com/a/:id` 才记为已发布;带草稿标识的编辑器地址,以及网易号、搜狐号或 InfoQ 页面明确提示草稿保存成功时才记为草稿。无法确定时进入“发布结果待确认”,不会伪报成功。
|
||||
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。CSDN 只有 `blog.csdn.net/:author/article/details/:id`、掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id` 或 `www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id` 或 `www.infoq.cn/article/:id`、腾讯云只有 `cloud.tencent.com/developer/article/:id`、阿里云只有 `developer.aliyun.com/article/:id`、思否只有 `segmentfault.com/a/:id`、51CTO 只有 `blog.51cto.com/u_:uid/:id` 才记为已发布;带草稿标识的编辑器地址,以及网易号、搜狐号、InfoQ、51CTO 或开源中国页面明确提示草稿保存成功时才记为草稿。开源中国的公开文章 URL 尚未在真实创作者账号上验收,因此未知页面会进入“发布结果待确认”,不会伪报成功。
|
||||
|
||||
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './distribution/cose/background.js';
|
||||
import './interaction/background.js';
|
||||
import './distribution/browser-agent/index.js';
|
||||
import { AUTO_FILL_PLATFORM_IDS } from './distribution/cose/core/platforms/index.js';
|
||||
|
||||
const PREPARE_BATCH_MESSAGE = 'PREPARE_PLATFORM_BATCH';
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
'syncToPlatform',
|
||||
'openPlatformTask',
|
||||
'inspectPlatformTask',
|
||||
'pairBrowserAgent',
|
||||
'getBrowserAgentStatus',
|
||||
'pollBrowserAgentNow',
|
||||
'disconnectBrowserAgent',
|
||||
]);
|
||||
|
||||
async function sendRuntimeMessage(message) {
|
||||
@@ -88,6 +92,17 @@
|
||||
expectedPlatformUid: payload.expectedPlatformUid,
|
||||
});
|
||||
}
|
||||
case 'pairBrowserAgent':
|
||||
return sendRuntimeMessage({
|
||||
type: 'PAIR_BROWSER_AGENT',
|
||||
payload,
|
||||
});
|
||||
case 'getBrowserAgentStatus':
|
||||
return sendRuntimeMessage({ type: 'GET_BROWSER_AGENT_STATUS' });
|
||||
case 'pollBrowserAgentNow':
|
||||
return sendRuntimeMessage({ type: 'POLL_BROWSER_AGENT_NOW' });
|
||||
case 'disconnectBrowserAgent':
|
||||
return sendRuntimeMessage({ type: 'DISCONNECT_BROWSER_AGENT' });
|
||||
default:
|
||||
throw new Error(`不支持的分发方法: ${method}`);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
tabId,
|
||||
expectedPlatformUid,
|
||||
}),
|
||||
pairBrowserAgent: payload => request('pairBrowserAgent', payload),
|
||||
getBrowserAgentStatus: () => request('getBrowserAgentStatus'),
|
||||
pollBrowserAgentNow: () => request('pollBrowserAgentNow'),
|
||||
disconnectBrowserAgent: () => request('disconnectBrowserAgent'),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
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',
|
||||
'http://localhost:3002',
|
||||
])
|
||||
|
||||
const BROWSER_AGENT_STORAGE_KEY = 'aitoearn_browser_agent_v1'
|
||||
const BROWSER_AGENT_ALARM_NAME = 'aitoearn-browser-agent-poll'
|
||||
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/*'],
|
||||
}
|
||||
|
||||
const PLATFORM_ACTIONS = {
|
||||
csdn: {
|
||||
draft: { selector: 'button.btn.btn-save', text: '保存草稿' },
|
||||
publish: { selector: 'button.btn.btn-publish', text: '发布文章' },
|
||||
},
|
||||
tencentcloud: {
|
||||
draft: { selector: 'button.cdc-btn.cdc-btn--hole', text: '存草稿' },
|
||||
publish: { selector: 'button.cdc-btn.cdc-btn--primary', text: '去发布' },
|
||||
},
|
||||
aliyun: {
|
||||
draft: { selector: 'button.public-button.gray-button', text: '存为草稿' },
|
||||
publish: { selector: 'button.public-button.orange-button', text: '发布文章' },
|
||||
},
|
||||
qiehao: {
|
||||
draft: { selector: 'button.omui-button.omui-button--background-grey', text: '存草稿' },
|
||||
publish: { selector: 'button.omui-button.omui-button--primary', text: '发布' },
|
||||
},
|
||||
}
|
||||
|
||||
let pollingPromise = null
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
const rawValue = typeof value === 'string' && value.trim()
|
||||
? value.trim()
|
||||
: 'https://wxapi.frp.it1024.cc'
|
||||
const url = new URL(rawValue)
|
||||
if (url.pathname !== '/' || url.search || url.hash || !ALLOWED_API_ORIGINS.has(url.origin)) {
|
||||
throw new Error('浏览器 Agent 只允许连接已配置的 AiToEarn API 地址')
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getMessage(error, fallback) {
|
||||
if (error instanceof Error && error.message) return error.message.slice(0, 1000)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function unwrapApiPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') return payload
|
||||
if (typeof payload.code === 'number' && payload.code !== 0) {
|
||||
throw new Error(typeof payload.message === 'string' ? payload.message : '浏览器 Agent 请求失败')
|
||||
}
|
||||
return payload.data === undefined ? payload : payload.data
|
||||
}
|
||||
|
||||
async function requestBrowserAgent(agent, path, init = {}) {
|
||||
const headers = new Headers(init.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
if (agent?.token) headers.set('x-aitoearn-browser-agent', agent.token)
|
||||
if (init.body !== undefined) headers.set('Content-Type', 'application/json')
|
||||
|
||||
const response = await fetch(agent.apiBaseUrl + path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
let payload
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new Error('AiToEarn API 返回了无效响应')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = payload && typeof payload === 'object' && typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '浏览器 Agent 请求失败:' + response.status
|
||||
throw new Error(message)
|
||||
}
|
||||
return unwrapApiPayload(payload)
|
||||
}
|
||||
|
||||
async function getStoredBrowserAgent() {
|
||||
const result = await chrome.storage.local.get(BROWSER_AGENT_STORAGE_KEY)
|
||||
const stored = result?.[BROWSER_AGENT_STORAGE_KEY]
|
||||
if (
|
||||
!stored
|
||||
|| typeof stored !== 'object'
|
||||
|| typeof stored.agentId !== 'string'
|
||||
|| typeof stored.token !== 'string'
|
||||
|| typeof stored.apiBaseUrl !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
async function saveStoredBrowserAgent(agent) {
|
||||
await chrome.storage.local.set({
|
||||
[BROWSER_AGENT_STORAGE_KEY]: agent,
|
||||
})
|
||||
}
|
||||
|
||||
function toPublicAgentState(agent, extra = {}) {
|
||||
if (!agent) {
|
||||
return {
|
||||
paired: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
return {
|
||||
paired: true,
|
||||
agentId: agent.agentId,
|
||||
apiBaseUrl: agent.apiBaseUrl,
|
||||
pairedAt: agent.pairedAt,
|
||||
runtime: agent.runtime || null,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRuntimeState(agent, runtime) {
|
||||
const next = {
|
||||
...agent,
|
||||
runtime: {
|
||||
...agent.runtime,
|
||||
...runtime,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
await saveStoredBrowserAgent(next)
|
||||
return next
|
||||
}
|
||||
|
||||
async function syncPollingAlarm() {
|
||||
await chrome.alarms.create(BROWSER_AGENT_ALARM_NAME, {
|
||||
periodInMinutes: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async function pairBrowserAgent(payload) {
|
||||
if (!payload || typeof payload !== 'object') throw new Error('缺少浏览器扩展配对信息')
|
||||
if (typeof payload.agentId !== 'string' || !payload.agentId.trim()) throw new Error('缺少浏览器 Agent ID')
|
||||
if (typeof payload.enrollmentCode !== 'string' || payload.enrollmentCode.length < 32) {
|
||||
throw new Error('浏览器扩展配对码无效')
|
||||
}
|
||||
|
||||
const apiBaseUrl = normalizeApiBaseUrl(payload.apiBaseUrl)
|
||||
const exchange = await requestBrowserAgent(
|
||||
{ apiBaseUrl },
|
||||
'/v2/channels/browser-agents/enroll',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agentId: payload.agentId,
|
||||
enrollmentCode: payload.enrollmentCode,
|
||||
extensionVersion: getExtensionVersion(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!exchange || typeof exchange.token !== 'string' || typeof exchange.agentId !== 'string') {
|
||||
throw new Error('浏览器 Agent 配对响应无效')
|
||||
}
|
||||
|
||||
const agent = {
|
||||
agentId: exchange.agentId,
|
||||
token: exchange.token,
|
||||
apiBaseUrl,
|
||||
pairedAt: new Date().toISOString(),
|
||||
runtime: {
|
||||
status: 'paired',
|
||||
message: '浏览器 Agent 已配对,等待可执行任务',
|
||||
},
|
||||
}
|
||||
await saveStoredBrowserAgent(agent)
|
||||
await syncPollingAlarm()
|
||||
return toPublicAgentState(agent)
|
||||
}
|
||||
|
||||
async function getBrowserAgentStatus() {
|
||||
const agent = await getStoredBrowserAgent()
|
||||
if (!agent) return toPublicAgentState(null)
|
||||
|
||||
try {
|
||||
const session = await requestBrowserAgent(agent, '/v2/channels/browser-agents/session/current')
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'online',
|
||||
session,
|
||||
lastError: undefined,
|
||||
})
|
||||
return toPublicAgentState(next, { session })
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'offline',
|
||||
lastError: getMessage(error, '无法连接浏览器 Agent 服务'),
|
||||
})
|
||||
return toPublicAgentState(next)
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectBrowserAgent() {
|
||||
await chrome.storage.local.remove(BROWSER_AGENT_STORAGE_KEY)
|
||||
return { paired: false, disconnected: true }
|
||||
}
|
||||
|
||||
async function hasCurrentPlatformTab(platformId) {
|
||||
const patterns = PLATFORM_TAB_PATTERNS[platformId] || []
|
||||
if (patterns.length === 0) return false
|
||||
const tabs = await chrome.tabs.query({ url: patterns })
|
||||
return tabs.some(tab => tab.id && !tab.discarded)
|
||||
}
|
||||
|
||||
async function inspectCurrentPlatformAccount(platformId) {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GET_PLATFORM_ACCOUNT',
|
||||
platformId,
|
||||
})
|
||||
if (!response?.success || !response.platformUid) {
|
||||
return {
|
||||
account: null,
|
||||
error: response?.error
|
||||
|| (response?.loggedIn
|
||||
? '已检测到登录状态,但平台没有返回稳定账号标识'
|
||||
: '平台登录状态未通过核对'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
account: {
|
||||
platform: platformId,
|
||||
platformUid: String(response.platformUid),
|
||||
displayName: response.displayName || String(response.platformUid),
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
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 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, checks }
|
||||
}
|
||||
|
||||
async function listCurrentPlatformAccounts() {
|
||||
return (await inspectCurrentPlatformAccounts()).accounts
|
||||
}
|
||||
|
||||
function toTaskContent(task) {
|
||||
const body = typeof task.body === 'string' ? task.body : ''
|
||||
const isHtml = /<[a-z][\w:-]*(?:\s[^<>]*)?>/i.test(body)
|
||||
return {
|
||||
title: typeof task.title === 'string' ? task.title : '',
|
||||
body,
|
||||
markdown: body,
|
||||
html: isHtml ? body : renderToutiaoMarkdown(body),
|
||||
plainText: isHtml ? body.replace(/<[^>]+>/g, ' ') : renderToutiaoPlainText(body),
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskResult(task, account, status, extra = {}) {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
status,
|
||||
platformUid: account.platformUid,
|
||||
extensionVersion: getExtensionVersion(),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
async function clickPlatformAction(platformId, tabId, publishMode, providerConfig) {
|
||||
const action = getPlatformAction(platformId, publishMode, providerConfig)
|
||||
if (!action) {
|
||||
return {
|
||||
success: false,
|
||||
message: '该平台未定义受控' + (publishMode === 'draft' ? '存草稿' : '发布') + '动作',
|
||||
}
|
||||
}
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (selector, text) => {
|
||||
const normalize = value => String(value || '').replace(/\s+/g, '')
|
||||
const button = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => normalize(candidate.textContent) === normalize(text))
|
||||
if (!button) return { success: false, message: '未找到目标操作按钮' }
|
||||
if (button.disabled || button.getAttribute('aria-disabled') === 'true') {
|
||||
return { success: false, message: '目标操作按钮当前不可用' }
|
||||
}
|
||||
button.click()
|
||||
return { success: true }
|
||||
},
|
||||
args: [action.selector, action.text],
|
||||
world: 'MAIN',
|
||||
})
|
||||
return result?.[0]?.result || { success: false, message: '无法执行平台操作' }
|
||||
}
|
||||
|
||||
async function inspectTaskState(task, tabId) {
|
||||
return await chrome.runtime.sendMessage({
|
||||
type: 'INSPECT_PLATFORM_TASK',
|
||||
platformId: task.platform,
|
||||
tabId,
|
||||
expectedPlatformUid: task.platformUid,
|
||||
})
|
||||
}
|
||||
|
||||
async function executeClaimedTask(task) {
|
||||
if (!BROWSER_AGENT_PLATFORM_IDS.includes(task.platform)) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前扩展版本未开放该平台的浏览器 Agent 执行器',
|
||||
}
|
||||
}
|
||||
const account = await getCurrentPlatformAccount(task.platform)
|
||||
if (!account) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前浏览器无法确认任务绑定的平台账号',
|
||||
}
|
||||
}
|
||||
if (account.platformUid !== task.platformUid) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前浏览器登录账号与任务绑定账号不一致,任务将等待租约过期后重新领取',
|
||||
}
|
||||
}
|
||||
if (Date.parse(task.handoff?.expiresAt || '') <= Date.now()) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: '浏览器发布任务租约已过期,未执行平台操作',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
type: 'OPEN_PLATFORM_TASK',
|
||||
platformId: task.platform,
|
||||
content: toTaskContent(task),
|
||||
expectedPlatformUid: task.platformUid,
|
||||
providerConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: getMessage(error, '打开平台编辑器失败'),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (!opened?.success || !Number.isInteger(opened.tabId) || opened.tabId <= 0) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: opened?.error || opened?.message || '平台编辑器内容填充失败',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
tabId: opened.tabId,
|
||||
url: opened.url,
|
||||
message: clickResult.message || '平台操作未执行',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1600))
|
||||
const inspected = await inspectTaskState(task, opened.tabId)
|
||||
if (!inspected?.success && inspected?.status !== 'publication_uncertain') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: inspected?.error || '无法核对平台操作结果',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (task.handoff.publishMode === 'draft') {
|
||||
if (inspected.status === 'draft_saved') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'draft_saved', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'publication_uncertain', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: inspected?.message || '已点击存草稿,但平台未返回可确认的草稿结果',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (inspected.status === 'published') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'published', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (inspected.status === 'draft_saved') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'draft_saved', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'publication_uncertain', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: '已点击第一层发布按钮,但未确认最终公开结果;平台可能需要人工二次确认',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function pollBrowserAgentNow() {
|
||||
if (pollingPromise) return pollingPromise
|
||||
|
||||
pollingPromise = (async () => {
|
||||
const agent = await getStoredBrowserAgent()
|
||||
if (!agent) return { success: false, status: 'not_paired' }
|
||||
|
||||
const { accounts, checks: accountChecks } = await inspectCurrentPlatformAccounts()
|
||||
if (accounts.length === 0) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'waiting_for_platform_account',
|
||||
message: accountChecks.length > 0
|
||||
? `已打开 ${accountChecks.length} 个受支持平台,但账号核对未通过`
|
||||
: '未找到已打开的受支持平台标签页',
|
||||
lastError: undefined,
|
||||
accounts: [],
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: true, accounts: [], accountChecks })
|
||||
}
|
||||
|
||||
let claimed
|
||||
try {
|
||||
claimed = await requestBrowserAgent(agent, '/v2/channels/browser-agents/claim', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
extensionVersion: getExtensionVersion(),
|
||||
accounts: accounts.map(account => ({
|
||||
platform: account.platform,
|
||||
platformUid: account.platformUid,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'offline',
|
||||
lastError: getMessage(error, '领取浏览器任务失败'),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks })
|
||||
}
|
||||
|
||||
const task = claimed?.task
|
||||
if (!task) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'idle',
|
||||
message: '没有等待执行的浏览器发布任务',
|
||||
lastError: undefined,
|
||||
lastClaimAt: new Date().toISOString(),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: true, accounts, accountChecks, task: null })
|
||||
}
|
||||
|
||||
const execution = await executeClaimedTask(task)
|
||||
if (!execution.report) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'account_mismatch',
|
||||
message: execution.message,
|
||||
lastTaskId: task.taskId,
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
|
||||
}
|
||||
|
||||
try {
|
||||
await requestBrowserAgent(agent, '/v2/channels/browser-agents/' + encodeURIComponent(task.taskId) + '/result', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(execution.result),
|
||||
})
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: execution.result.status,
|
||||
message: execution.result.message,
|
||||
lastError: undefined,
|
||||
lastTaskId: task.taskId,
|
||||
lastResultAt: new Date().toISOString(),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, {
|
||||
success: true,
|
||||
accounts,
|
||||
accountChecks,
|
||||
taskId: task.taskId,
|
||||
result: execution.result,
|
||||
})
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'result_sync_failed',
|
||||
message: '平台操作已完成,但结果回写失败',
|
||||
lastError: getMessage(error, '浏览器任务结果回写失败'),
|
||||
lastTaskId: task.taskId,
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
|
||||
}
|
||||
})().finally(() => {
|
||||
pollingPromise = null
|
||||
})
|
||||
|
||||
return pollingPromise
|
||||
}
|
||||
|
||||
function installBrowserAgentRuntime() {
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
void syncPollingAlarm()
|
||||
})
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
void syncPollingAlarm()
|
||||
})
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === BROWSER_AGENT_ALARM_NAME) {
|
||||
void pollBrowserAgentNow()
|
||||
}
|
||||
})
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (![
|
||||
'PAIR_BROWSER_AGENT',
|
||||
'GET_BROWSER_AGENT_STATUS',
|
||||
'POLL_BROWSER_AGENT_NOW',
|
||||
'DISCONNECT_BROWSER_AGENT',
|
||||
].includes(request?.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
switch (request.type) {
|
||||
case 'PAIR_BROWSER_AGENT':
|
||||
sendResponse(await pairBrowserAgent(request.payload))
|
||||
return
|
||||
case 'GET_BROWSER_AGENT_STATUS':
|
||||
sendResponse(await getBrowserAgentStatus())
|
||||
return
|
||||
case 'POLL_BROWSER_AGENT_NOW':
|
||||
sendResponse(await pollBrowserAgentNow())
|
||||
return
|
||||
case 'DISCONNECT_BROWSER_AGENT':
|
||||
sendResponse(await disconnectBrowserAgent())
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
sendResponse({ error: getMessage(error, '浏览器 Agent 操作失败') })
|
||||
}
|
||||
})()
|
||||
return true
|
||||
})
|
||||
void syncPollingAlarm()
|
||||
}
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage && chrome.alarms) {
|
||||
installBrowserAgentRuntime()
|
||||
}
|
||||
|
||||
export {
|
||||
ALLOWED_API_ORIGINS,
|
||||
BROWSER_AGENT_PLATFORM_IDS,
|
||||
PLATFORM_TAB_PATTERNS,
|
||||
executeClaimedTask,
|
||||
getPlatformAction,
|
||||
getTaskExecutionPolicy,
|
||||
inspectCurrentPlatformAccounts,
|
||||
listCurrentPlatformAccounts,
|
||||
normalizeApiBaseUrl,
|
||||
toTaskContent,
|
||||
}
|
||||
+58
-131
@@ -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,
|
||||
@@ -305,42 +335,6 @@ async function getCsdnProfileFromSiteContext() {
|
||||
|
||||
globalThis.__coseCsdnProfile = getCsdnProfileFromSiteContext
|
||||
|
||||
/**
|
||||
* 51CTO detection via offscreen document (爱贝壳 approach).
|
||||
* Offscreen has document context so DOMParser is available.
|
||||
* Includes retry logic in case the offscreen document wasn't ready on first attempt.
|
||||
*/
|
||||
async function detectCto51ViaOffscreen() {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
console.log(`[COSE] 51CTO: Sending OFFSCREEN_DETECT_CTO51 (attempt ${attempt})...`)
|
||||
const result = await sendOffscreenMessage({
|
||||
type: 'OFFSCREEN_DETECT_CTO51',
|
||||
})
|
||||
console.log('[COSE] 51CTO: Offscreen response:', JSON.stringify(result))
|
||||
if (result === undefined || result === null) {
|
||||
// No listener responded — offscreen might not be ready
|
||||
console.warn('[COSE] 51CTO: Got empty response, offscreen may not be ready')
|
||||
if (attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result?.data || null
|
||||
} catch (e) {
|
||||
console.log(`[COSE] 51CTO offscreen detection failed (attempt ${attempt}):`, e.message)
|
||||
if (attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
continue
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
globalThis.__coseDetectCto51 = detectCto51ViaOffscreen
|
||||
|
||||
/**
|
||||
* Cnblogs detection via offscreen document.
|
||||
* Offscreen has document context so cookies are sent automatically.
|
||||
@@ -579,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(
|
||||
@@ -780,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: '缺少待填充内容' }
|
||||
}
|
||||
@@ -801,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) {
|
||||
@@ -826,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) {
|
||||
@@ -965,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) {
|
||||
@@ -1753,22 +1772,8 @@ async function syncToPlatform(platformId, content, options = {}) {
|
||||
} else [DISABLED] */
|
||||
if (platformId !== 'wechat' && !tab) {
|
||||
// 其他平台(排除微信,因为微信在上面已经处理)
|
||||
let targetUrl = platform.publishUrl
|
||||
|
||||
// 开源中国:使用 ai-write 编辑器,需要用户 ID
|
||||
if (platformId === 'oschina') {
|
||||
const stored = await chrome.storage.local.get('oschina_userId')
|
||||
const userId = stored?.oschina_userId
|
||||
if (userId) {
|
||||
targetUrl = `https://my.oschina.net/u/${userId}/blog/ai-write`
|
||||
console.log('[COSE] 使用 OSChina AI 写作 URL:', targetUrl)
|
||||
} else {
|
||||
console.warn('[COSE] 未找到 OSChina 用户 ID,使用默认 URL')
|
||||
}
|
||||
}
|
||||
|
||||
// 直接打开发布页面
|
||||
tab = await chrome.tabs.create({ url: targetUrl, active: false })
|
||||
tab = await chrome.tabs.create({ url: platform.publishUrl, active: false })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
await waitForTab(tab.id)
|
||||
}
|
||||
@@ -3595,84 +3600,6 @@ function fillContentOnPage(content, platformId) {
|
||||
console.log('[COSE] 头条未找到编辑器')
|
||||
}
|
||||
}
|
||||
// 开源中国 OSChina (AI 写作平台 - 切换到 Markdown 编辑器)
|
||||
else if (host.includes('oschina.net')) {
|
||||
// 1. 切换到 MD 编辑器(如果当前不是)
|
||||
const switchText = document.querySelector('.editor-switch-text')
|
||||
if (switchText && switchText.textContent.includes('切换到MD编辑器')) {
|
||||
// Click the switch button to trigger confirmation dialog
|
||||
const switchBtn = document.querySelector('.editor-switch-btn') || switchText.parentElement
|
||||
if (switchBtn) {
|
||||
switchBtn.click()
|
||||
console.log('[COSE] OSChina 已点击切换按钮')
|
||||
// Poll for the confirmation button (Ant Design Modal animation)
|
||||
let confirmBtn = null
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
confirmBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
btn => btn.textContent.trim() === '确定切换'
|
||||
)
|
||||
if (confirmBtn) break
|
||||
}
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click()
|
||||
console.log('[COSE] OSChina 已确认切换到MD编辑器')
|
||||
} else {
|
||||
console.log('[COSE] OSChina 未找到确认切换按钮')
|
||||
}
|
||||
// Wait for MD editor to load after switch
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
} else {
|
||||
console.log('[COSE] OSChina 已在MD编辑器模式')
|
||||
}
|
||||
|
||||
// 2. 填充标题
|
||||
const titleInput = await waitFor('input[placeholder*="标题"]')
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.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] OSChina 标题填充成功')
|
||||
}
|
||||
|
||||
// 3. 填充 Markdown 内容到 textarea
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const mdContent = markdown || contentToFill
|
||||
// Poll for textarea (may take time after editor switch)
|
||||
let textarea = null
|
||||
for (let i = 0; i < 10; i++) {
|
||||
textarea = document.querySelector('textarea')
|
||||
if (textarea) break
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
}
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (textareaSetter) {
|
||||
textareaSetter.call(textarea, mdContent)
|
||||
} else {
|
||||
textarea.value = mdContent
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] OSChina Markdown 内容填充成功,长度:', mdContent.length)
|
||||
} else {
|
||||
console.log('[COSE] OSChina 未找到 Markdown textarea')
|
||||
}
|
||||
}
|
||||
// 博客园
|
||||
else if (host.includes('cnblogs.com')) {
|
||||
// 等待页面加载
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -18,10 +18,6 @@ function normalizeString(value) {
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function parseAliyunPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
@@ -100,8 +96,41 @@ function inspectAliyunTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
|
||||
function fillAliyunContent(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
@@ -173,15 +202,23 @@ function fillAliyunContent(title, markdown, html, plainText) {
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(normalizeText(markdownContent).length * 0.1)), 120)
|
||||
return normalizeText(value).length >= minimumLength
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const textLength = normalizeText(editor?.innerText || editor?.textContent).length
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(normalizeText(plainText).length * 0.1)), 120)
|
||||
return textLength + imageCount * 12 >= minimumLength
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function switchToMarkdownEditor() {
|
||||
@@ -206,7 +243,13 @@ function fillAliyunContent(title, markdown, html, plainText) {
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到阿里云开发者社区标题输入框' }
|
||||
if (title) setInputValue(titleInput, title)
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) {
|
||||
return { success: false, error: '阿里云开发者社区未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
@@ -220,6 +263,9 @@ function fillAliyunContent(title, markdown, html, plainText) {
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.editor textarea.textarea',
|
||||
'textarea#article-editor',
|
||||
'textarea.textarea',
|
||||
'.markdown-editor textarea',
|
||||
'.editor-container textarea',
|
||||
'textarea[placeholder*="正文"]',
|
||||
|
||||
@@ -92,6 +92,35 @@ function inspectBaijiahaoTaskState(rawUrl, publicUrl) {
|
||||
function fillBaijiahaoContent(title, html, plainText) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function waitForCondition(getValue, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
@@ -168,9 +197,13 @@ function fillBaijiahaoContent(title, html, plainText) {
|
||||
}
|
||||
|
||||
function hasExpectedContent(text, imageCount) {
|
||||
const expectedTextLength = normalizeText(plainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
return normalizeText(text).length + imageCount * 12 >= minimumLength
|
||||
const editorText = normalizeComparableText(text)
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
@@ -181,7 +214,13 @@ function fillBaijiahaoContent(title, html, plainText) {
|
||||
|| document.querySelector('[contenteditable="true"][placeholder*="标题"]')
|
||||
))
|
||||
if (!titleEditor) return { success: false, error: '未找到百家号标题输入框' }
|
||||
if (title) setEditableText(titleEditor, title)
|
||||
if (title) {
|
||||
setEditableText(titleEditor, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleEditor.textContent) !== normalizeText(title)) {
|
||||
return { success: false, error: '百家号未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return { success: true, method: 'title-only' }
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ function fillCsdnContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
@@ -142,21 +143,43 @@ function fillCsdnContent(title, markdown, body) {
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const expectedLength = normalizeText(contentToFill).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedLength * 0.1)), 120)
|
||||
const actualLength = normalizeText(editor?.value || editor?.innerText || editor?.textContent).length
|
||||
return actualLength >= minimumLength
|
||||
const actualContent = editor?.value ?? editor?.innerText ?? editor?.textContent
|
||||
return normalizeContent(actualContent) === normalizeContent(contentToFill)
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
if (!expectedTitle) return true
|
||||
|
||||
const inputTitle = normalizeText(input?.value)
|
||||
const displayTitle = normalizeText(document.querySelector('.article-bar__title-display')?.textContent)
|
||||
return inputTitle === expectedTitle || displayTitle === expectedTitle
|
||||
}
|
||||
|
||||
async function getTitleInput() {
|
||||
const selectors = [
|
||||
'input.article-bar__title--input',
|
||||
'.article-bar__input-box input[placeholder*="标题"]',
|
||||
'input[placeholder*="文章标题"]',
|
||||
'input[name="title"]',
|
||||
'input.title-input',
|
||||
]
|
||||
const visibleInput = findFirst(selectors)
|
||||
if (visibleInput) return visibleInput
|
||||
|
||||
const titleDisplay = findFirst(['.article-bar__title-display'])
|
||||
titleDisplay?.click()
|
||||
return waitForElement(selectors, undefined, 2500)
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'.article-bar__title input',
|
||||
'input[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
'input.title-input',
|
||||
])
|
||||
const titleInput = title ? await getTitleInput() : null
|
||||
if (title && !titleInput) return { success: false, error: '未找到 CSDN 标题输入框' }
|
||||
if (titleInput && title) setInputValue(titleInput, title)
|
||||
if (titleInput && title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) return { success: false, error: 'CSDN 未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
@@ -175,7 +198,7 @@ function fillCsdnContent(title, markdown, body) {
|
||||
editor.CodeMirror.setValue(contentToFill)
|
||||
editor.CodeMirror.focus?.()
|
||||
await sleep(500)
|
||||
if (hasExpectedContent(editor.CodeMirror.getWrapperElement?.() || editor)) {
|
||||
if (normalizeContent(editor.CodeMirror.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
} else if (editor instanceof HTMLTextAreaElement) {
|
||||
|
||||
@@ -1,72 +1,333 @@
|
||||
// 51CTO 平台配置
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const CTO51Platform = {
|
||||
id: 'cto51',
|
||||
id: '51cto',
|
||||
name: '51CTO',
|
||||
icon: 'https://blog.51cto.com/favicon.ico',
|
||||
url: 'https://blog.51cto.com',
|
||||
loginUrl: 'https://home.51cto.com/index/login',
|
||||
publishUrl: 'https://blog.51cto.com/blogger/publish',
|
||||
title: '51CTO',
|
||||
type: 'cto51',
|
||||
type: '51cto',
|
||||
}
|
||||
|
||||
// 51CTO 内容填充函数
|
||||
async function fillCTO51Content(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
const contentToFill = markdown || body || ''
|
||||
function parseCto51PublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
// 1. 填充标题
|
||||
// 51CTO 标题输入框通常是 input#title 或 placeholder="请输入标题"
|
||||
const titleInput = await waitFor('#title, input[placeholder*="标题"]')
|
||||
if (titleInput) {
|
||||
setInputValue(titleInput, title)
|
||||
console.log('[COSE] 51CTO 标题填充成功')
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://blog.51cto.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.51cto.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/u_(\d+)\/(\d+)\/?$/)
|
||||
if (!published?.[2]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[2],
|
||||
message: '已确认 51CTO 文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectCto51TaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseCto51PublishedUrl(publicUrl) || parseCto51PublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取 51CTO 编辑器标签页地址' }
|
||||
}
|
||||
|
||||
// 2. 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.51cto.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开 51CTO,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 填充内容
|
||||
// 51CTO 有 Markdown 编辑器和富文本编辑器,通常默认 Markdown
|
||||
// 尝试寻找 Markdown 编辑器的 textarea 或 CodeMirror
|
||||
const editor =
|
||||
document.querySelector('.editormd-markdown-textarea') || // Editor.md
|
||||
document.querySelector('#my-editormd-markdown-doc') || // 常见 ID
|
||||
document.querySelector('.CodeMirror textarea') || // CodeMirror 核心
|
||||
document.querySelector('textarea[name="content"]') // 通用 fallback
|
||||
|
||||
if (editor) {
|
||||
// 如果是 CodeMirror,通常需要操作 DOM 或使用 setValue
|
||||
// 尝试直接设置 value 并触发事件
|
||||
editor.focus()
|
||||
editor.value = contentToFill
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
// 如果页面上有 editor.md 的全局实例,尝试调用
|
||||
// 这需要在 page context 执行,目前 fillContentOnPage 是在 Main world 执行的,所以可以访问 window
|
||||
if (window.editor) {
|
||||
try {
|
||||
window.editor.setMarkdown(contentToFill)
|
||||
console.log('[COSE] 51CTO 通过 window.editor 设置成功')
|
||||
return
|
||||
} catch (e) {
|
||||
console.log('[COSE] 51CTO window.editor 调用失败', e)
|
||||
if (/^\/blogger\/publish\/?$/.test(url.pathname)) {
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认 51CTO 编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[COSE] 51CTO textarea 填充尝试完成')
|
||||
} else {
|
||||
console.log('[COSE] 51CTO 未找到编辑器元素,尝试降级 contenteditable')
|
||||
|
||||
// 可能是富文本模式的 contenteditable
|
||||
const contentEditable = document.querySelector('[contenteditable="true"]')
|
||||
if (contentEditable) {
|
||||
contentEditable.innerHTML = contentToFill.replace(/\n/g, '<br>')
|
||||
console.log('[COSE] 51CTO contenteditable 填充成功')
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到 51CTO 编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前 51CTO 页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { CTO51Platform, fillCTO51Content }
|
||||
function fillCto51Content(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'#title',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到 51CTO 标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(100)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '51CTO 未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const codeMirrorEditor = codeMirror?.CodeMirror
|
||||
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
|
||||
codeMirrorEditor.focus()
|
||||
codeMirrorEditor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
}
|
||||
|
||||
const markdownTextarea = await waitForElement([
|
||||
'.editormd-markdown-textarea',
|
||||
'textarea[name="content"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (markdownTextarea) {
|
||||
setInputValue(markdownTextarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(markdownTextarea.value)) {
|
||||
return { success: true, method: 'markdown-textarea' }
|
||||
}
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '51CTO 未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectCto51Task(tab, helpers) {
|
||||
const currentState = inspectCto51TaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'blog.51cto.com'
|
||||
&& /^\/u_\d+\/\d+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const feedbackText = Array.from(document.querySelectorAll(
|
||||
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
|
||||
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectCto51TaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCto51Content(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取 51CTO 编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillCto51Content,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '51CTO 内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充 51CTO 编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
CTO51Platform,
|
||||
fillCto51Content,
|
||||
inspectCto51Task,
|
||||
inspectCto51TaskState,
|
||||
parseCto51PublishedUrl,
|
||||
syncCto51Content,
|
||||
}
|
||||
|
||||
@@ -22,8 +22,21 @@ import {
|
||||
syncSegmentFaultContent,
|
||||
} from './segmentfault.js'
|
||||
import { CnblogsPlatform } from './cnblogs.js'
|
||||
import { OSChinaPlatform } from './oschina.js'
|
||||
import { CTO51Platform } from './cto51.js'
|
||||
import {
|
||||
inspectOSChinaTask,
|
||||
OSChinaPlatform,
|
||||
syncOSChinaContent,
|
||||
} from './oschina.js'
|
||||
import {
|
||||
inspectQiehaoTask,
|
||||
QiehaoPlatform,
|
||||
syncQiehaoContent,
|
||||
} from './qiehao.js'
|
||||
import {
|
||||
CTO51Platform,
|
||||
inspectCto51Task,
|
||||
syncCto51Content,
|
||||
} from './cto51.js'
|
||||
import {
|
||||
InfoQPlatform,
|
||||
inspectInfoQTask,
|
||||
@@ -85,6 +98,9 @@ const AUTO_FILL_PLATFORM_IDS = new Set([
|
||||
'tencentcloud',
|
||||
'aliyun',
|
||||
'segmentfault',
|
||||
'51cto',
|
||||
'oschina',
|
||||
'qiehao',
|
||||
])
|
||||
|
||||
const AUTO_FILL_UNAVAILABLE_MESSAGE = '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。'
|
||||
@@ -99,6 +115,7 @@ const PLATFORM_DEFINITIONS = [
|
||||
SegmentFaultPlatform,
|
||||
CnblogsPlatform,
|
||||
OSChinaPlatform,
|
||||
QiehaoPlatform,
|
||||
CTO51Platform,
|
||||
InfoQPlatform,
|
||||
JianshuPlatform,
|
||||
@@ -152,6 +169,9 @@ const SYNC_HANDLERS = {
|
||||
tencentcloud: syncTencentCloudContent,
|
||||
aliyun: syncAliyunContent,
|
||||
segmentfault: syncSegmentFaultContent,
|
||||
'51cto': syncCto51Content,
|
||||
oschina: syncOSChinaContent,
|
||||
qiehao: syncQiehaoContent,
|
||||
}
|
||||
|
||||
const INSPECT_HANDLERS = {
|
||||
@@ -167,6 +187,9 @@ const INSPECT_HANDLERS = {
|
||||
tencentcloud: inspectTencentCloudTask,
|
||||
aliyun: inspectAliyunTask,
|
||||
segmentfault: inspectSegmentFaultTask,
|
||||
'51cto': inspectCto51Task,
|
||||
oschina: inspectOSChinaTask,
|
||||
qiehao: inspectQiehaoTask,
|
||||
}
|
||||
|
||||
// 导出
|
||||
|
||||
@@ -138,21 +138,59 @@ function fillInfoQContent(title, markdown, html, plainText) {
|
||||
const contentToFill = markdown || ''
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
@@ -160,7 +198,7 @@ function fillInfoQContent(title, markdown, html, plainText) {
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
@@ -199,11 +237,14 @@ function fillInfoQContent(title, markdown, html, plainText) {
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const expectedTextLength = normalizeText(plainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
const textLength = normalizeText(editor?.innerText || editor?.textContent).length
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
return textLength + imageCount * 12 >= minimumLength
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
function getVueCandidates(editor) {
|
||||
@@ -242,7 +283,14 @@ function fillInfoQContent(title, markdown, html, plainText) {
|
||||
'[contenteditable="true"][placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到 InfoQ 标题输入框' }
|
||||
if (title) setInputValue(titleInput, title)
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
|
||||
if (normalizeText(receivedTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: 'InfoQ 未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.gk-editor [contenteditable="true"]',
|
||||
@@ -250,7 +298,7 @@ function fillInfoQContent(title, markdown, html, plainText) {
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
], titleInput)
|
||||
if (!editor) return { success: false, error: '未找到 InfoQ 正文编辑器' }
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 简书平台配置
|
||||
const JianshuPlatform = {
|
||||
id: 'jianshu',
|
||||
@@ -99,39 +97,94 @@ async function inspectJianshuTask(tab, helpers) {
|
||||
|
||||
function fillJianshuContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(HTMLInputElement.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 }))
|
||||
}
|
||||
|
||||
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 }))
|
||||
const titleInput = title
|
||||
? await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input._24i7u',
|
||||
'input[class*="title"]',
|
||||
])
|
||||
: null
|
||||
if (title) {
|
||||
if (!titleInput) return { success: false, error: '未找到简书标题输入框' }
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeContent(titleInput.value) !== normalizeContent(title)) {
|
||||
return { success: false, error: '简书未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = document.querySelector('#arthur-editor')
|
||||
|| document.querySelector('textarea._3swFR')
|
||||
if (!editor) return { success: false, error: 'Editor not found' }
|
||||
const editor = await waitForElement([
|
||||
'textarea#arthur-editor',
|
||||
'textarea._3swFR',
|
||||
], titleInput, 5000)
|
||||
if (!editor) return { success: false, error: '未找到简书 Markdown 编辑器' }
|
||||
|
||||
setInputValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (normalizeContent(editor.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '简书未确认接收正文' }
|
||||
}
|
||||
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -181,7 +234,6 @@ async function syncJianshuContent(tab, content, helpers) {
|
||||
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 },
|
||||
|
||||
@@ -9,8 +9,6 @@ const JuejinPlatform = {
|
||||
type: 'juejin',
|
||||
}
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
function inspectJuejinTaskState(rawUrl) {
|
||||
let url
|
||||
try {
|
||||
@@ -60,43 +58,110 @@ function inspectJuejinTaskState(rawUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
// 掘金内容填充函数(在页面主世界中执行)
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
function fillJuejinContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(HTMLInputElement.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 }))
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
// 填充标题(使用注入的 window.waitFor)
|
||||
const titleInput = await window.waitFor('input[placeholder*="标题"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
titleInput.value = title
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 掘金使用 ByteMD 编辑器(基于 CodeMirror)
|
||||
const cmElement = document.querySelector('.CodeMirror')
|
||||
if (cmElement && cmElement.CodeMirror) {
|
||||
cmElement.CodeMirror.setValue(contentToFill)
|
||||
console.log('[COSE] 掘金 CodeMirror 填充成功')
|
||||
return { success: true, method: 'CodeMirror' }
|
||||
} else {
|
||||
// 降级到 textarea
|
||||
const textarea = document.querySelector('.bytemd-body textarea')
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.value = contentToFill
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
console.log('[COSE] 掘金 textarea 填充成功')
|
||||
return { success: true, method: 'textarea' }
|
||||
} else {
|
||||
console.log('[COSE] 掘金 未找到编辑器')
|
||||
return { success: false, error: 'Editor not found' }
|
||||
const titleInput = title
|
||||
? await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'.title-input input',
|
||||
'input.title-input',
|
||||
])
|
||||
: null
|
||||
if (title) {
|
||||
if (!titleInput) return { success: false, error: '未找到掘金标题输入框' }
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '掘金未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const cmElement = await waitForElement(['.CodeMirror'], titleInput, 3000)
|
||||
if (cmElement && cmElement.CodeMirror) {
|
||||
cmElement.CodeMirror.setValue(contentToFill)
|
||||
cmElement.CodeMirror.focus?.()
|
||||
await sleep(300)
|
||||
if (normalizeContent(cmElement.CodeMirror.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
return { success: false, error: '掘金未确认接收正文' }
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.bytemd-body textarea',
|
||||
'.bytemd-editor textarea',
|
||||
'.bytemd-split .bytemd-body textarea',
|
||||
], titleInput, 4000)
|
||||
if (!textarea) return { success: false, error: '未找到掘金 Markdown 编辑器' }
|
||||
|
||||
setInputValue(textarea, contentToFill)
|
||||
await sleep(300)
|
||||
if (normalizeContent(textarea.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '掘金未确认接收正文' }
|
||||
}
|
||||
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
@@ -112,13 +177,8 @@ function fillJuejinContent(title, markdown, body) {
|
||||
async function syncJuejinContent(tab, content, helpers) {
|
||||
const { chrome } = helpers
|
||||
|
||||
// 等待页面加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
await helpers.waitForTab(tab.id)
|
||||
|
||||
// 先注入公共工具函数(waitFor, setInputValue)
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 在页面中执行填充脚本
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillJuejinContent,
|
||||
|
||||
@@ -1,85 +1,317 @@
|
||||
// OSChina 平台配置
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const OSChinaPlatform = {
|
||||
id: 'oschina',
|
||||
name: 'OSChina',
|
||||
icon: 'https://wsrv.nl/?url=static.oschina.net/new-osc/img/favicon.ico',
|
||||
icon: 'https://www.oschina.net/favicon.ico',
|
||||
url: 'https://www.oschina.net',
|
||||
publishUrl: 'https://my.oschina.net/blog/ai-write',
|
||||
title: '开源中国',
|
||||
type: 'oschina',
|
||||
}
|
||||
|
||||
// OSChina 内容填充函数 (AI 写作平台 - 切换到 Markdown 编辑器)
|
||||
async function fillOSChinaContent(content, waitFor, setInputValue) {
|
||||
const { title, markdown, body } = content
|
||||
const mdContent = markdown || body || ''
|
||||
function isOSChinaEditorUrl(url) {
|
||||
return url.protocol === 'https:'
|
||||
&& url.hostname === 'my.oschina.net'
|
||||
&& /^\/(?:blog\/ai-write|u\/\d+\/blog\/ai-write|u\/draft\/new)\/?$/.test(url.pathname)
|
||||
}
|
||||
|
||||
// 1. 切换到 MD 编辑器(如果当前不是)
|
||||
const switchText = document.querySelector('.editor-switch-text')
|
||||
if (switchText && switchText.textContent.includes('切换到MD编辑器')) {
|
||||
const switchBtn = document.querySelector('.editor-switch-btn') || switchText.parentElement
|
||||
if (switchBtn) {
|
||||
switchBtn.click()
|
||||
let confirmBtn = null
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
confirmBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
btn => btn.textContent.trim() === '确定切换'
|
||||
)
|
||||
if (confirmBtn) break
|
||||
}
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click()
|
||||
console.log('[COSE] OSChina 已确认切换到MD编辑器')
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
/**
|
||||
* 开源中国的公开文章地址尚未经过真实创作者账号验证。
|
||||
* 因此只确认编辑器和草稿状态,避免把未知页面误记为已发布。
|
||||
*/
|
||||
function inspectOSChinaTaskState(rawUrl, draftSaved = false) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取开源中国编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (!isOSChinaEditorUrl(url)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前开源中国页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 填充标题
|
||||
const titleInput = await waitFor('input[placeholder*="标题"]')
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(titleInput, title)
|
||||
} else {
|
||||
titleInput.value = title
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认开源中国编辑器显示草稿保存成功',
|
||||
}
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] OSChina 标题填充成功')
|
||||
}
|
||||
|
||||
// 3. 填充 Markdown 内容到 textarea
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
let textarea = null
|
||||
for (let i = 0; i < 10; i++) {
|
||||
textarea = document.querySelector('textarea')
|
||||
if (textarea) break
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
}
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (textareaSetter) {
|
||||
textareaSetter.call(textarea, mdContent)
|
||||
} else {
|
||||
textarea.value = mdContent
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] OSChina Markdown 内容填充成功')
|
||||
} else {
|
||||
console.log('[COSE] OSChina 未找到 Markdown textarea')
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到开源中国编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { OSChinaPlatform, fillOSChinaContent }
|
||||
function fillOSChinaContent(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function switchToMarkdownEditor() {
|
||||
const switchButton = Array.from(document.querySelectorAll('button, [role="button"], a'))
|
||||
.find(element => {
|
||||
const text = element.textContent?.replace(/\s+/g, '') || ''
|
||||
return /切换到(?:Markdown|MD)编辑器|(?:Markdown|MD)编辑器/.test(text)
|
||||
})
|
||||
|| document.querySelector('.editor-switch-btn')
|
||||
if (!switchButton) return
|
||||
|
||||
switchButton.click()
|
||||
await sleep(500)
|
||||
const confirmButton = Array.from(document.querySelectorAll('button')).find(button =>
|
||||
/确定切换|确认切换/.test(button.textContent?.trim() || ''),
|
||||
)
|
||||
if (confirmButton) {
|
||||
confirmButton.click()
|
||||
await sleep(1200)
|
||||
}
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
'.title-input input',
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到开源中国标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(100)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '开源中国未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const codeMirrorEditor = codeMirror?.CodeMirror
|
||||
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
|
||||
codeMirrorEditor.focus()
|
||||
codeMirrorEditor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.editormd-markdown-textarea',
|
||||
'.markdown-editor textarea',
|
||||
'textarea[name="content"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (textarea) {
|
||||
setInputValue(textarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: 'markdown-textarea' }
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.cke_editable[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '开源中国未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectOSChinaTask(tab, helpers) {
|
||||
const currentState = inspectOSChinaTaskState(tab?.url || '')
|
||||
if (!tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const feedbackText = Array.from(document.querySelectorAll(
|
||||
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
|
||||
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
|
||||
return {
|
||||
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
|
||||
}
|
||||
},
|
||||
})
|
||||
return inspectOSChinaTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOSChinaContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取开源中国编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillOSChinaContent,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '开源中国内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充开源中国编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
OSChinaPlatform,
|
||||
fillOSChinaContent,
|
||||
inspectOSChinaTask,
|
||||
inspectOSChinaTaskState,
|
||||
syncOSChinaContent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
const QiehaoPlatform = {
|
||||
id: 'qiehao',
|
||||
name: 'Qiehao',
|
||||
icon: 'https://om.qq.com/favicon.ico',
|
||||
url: 'https://om.qq.com/',
|
||||
publishUrl: 'https://om.qq.com/main/creation/article',
|
||||
title: '企鹅号',
|
||||
type: 'qiehao',
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getQiehaoDraftId(url) {
|
||||
return normalizeString(
|
||||
url.searchParams.get('draftId')
|
||||
|| url.searchParams.get('draft_id')
|
||||
|| url.searchParams.get('articleId')
|
||||
|| url.searchParams.get('article_id'),
|
||||
)
|
||||
}
|
||||
|
||||
function parseQiehaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'page.om.qq.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/page\/([^/.]+)\.html$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认企鹅号文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectQiehaoTaskState(rawUrl, draftSaved = false) {
|
||||
const published = parseQiehaoPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取企鹅号编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'om.qq.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开企鹅号编辑器,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/main/creation/article') {
|
||||
const draftId = getQiehaoDraftId(url)
|
||||
if (draftSaved || draftId) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: draftId
|
||||
? '已确认企鹅号草稿地址'
|
||||
: '已确认企鹅号编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到企鹅号编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前企鹅号页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillQiehaoContent(title, html, plainText) {
|
||||
const contentHtml = html || ''
|
||||
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = contentHtml
|
||||
? htmlToComparableText(contentHtml)
|
||||
: normalizeComparableText(contentText)
|
||||
const expectedImageCount = Array.from(contentHtml.matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors
|
||||
.map(selector => document.querySelector(selector))
|
||||
.find(element => element && element.offsetParent !== null)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditor(editor, nextHtml, nextText) {
|
||||
editor.focus()
|
||||
selectContents(editor)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedTitle(editor) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(editor?.textContent) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const actualImageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || actualImageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleEditor = await waitForElement([
|
||||
'.omui-inputautogrowing__inner[contenteditable="true"][data-placeholder*="请输入标题"]',
|
||||
])
|
||||
if (!titleEditor) return { success: false, error: '未找到企鹅号标题输入框' }
|
||||
if (title) {
|
||||
updateEditor(titleEditor, String(title).replace(/</g, '<'), title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleEditor)) return { success: false, error: '企鹅号未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentHtml) return { success: true, method: 'title-only' }
|
||||
|
||||
const articleEditor = await waitForElement([
|
||||
'.ProseMirror.ExEditor-basic[contenteditable="true"]',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
])
|
||||
if (!articleEditor) return { success: false, error: '未找到企鹅号正文编辑器' }
|
||||
|
||||
updateEditor(articleEditor, contentHtml, contentText)
|
||||
await sleep(500)
|
||||
if (!hasExpectedContent(articleEditor)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '企鹅号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectQiehaoTask(tab, helpers) {
|
||||
const currentState = inspectQiehaoTaskState(tab?.url || '')
|
||||
if (!tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => ({
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|草稿保存成功/.test(
|
||||
document.body?.innerText || '',
|
||||
),
|
||||
}),
|
||||
})
|
||||
return inspectQiehaoTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncQiehaoContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取企鹅号编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const html = content.html || content.body || content.markdown || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillQiehaoContent,
|
||||
args: [content.title, html, content.plainText || html.replace(/<[^>]+>/g, ' ')],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '企鹅号内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充企鹅号文章编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fillQiehaoContent,
|
||||
inspectQiehaoTask,
|
||||
inspectQiehaoTaskState,
|
||||
parseQiehaoPublishedUrl,
|
||||
QiehaoPlatform,
|
||||
syncQiehaoContent,
|
||||
}
|
||||
@@ -83,6 +83,7 @@ function inspectSegmentFaultTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
function fillSegmentFaultContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
@@ -151,11 +152,13 @@ function fillSegmentFaultContent(title, markdown, body) {
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const expectedLength = normalizeText(contentToFill).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedLength * 0.1)), 120)
|
||||
const actualLength = normalizeText(editor?.value || editor?.innerText || editor?.textContent).length
|
||||
return actualLength >= minimumLength
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedContent(value) {
|
||||
return normalizeContent(value) === normalizeContent(contentToFill)
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
@@ -166,7 +169,11 @@ function fillSegmentFaultContent(title, markdown, body) {
|
||||
'.title-input input',
|
||||
])
|
||||
if (title && !titleInput) return { success: false, error: '未找到思否标题输入框' }
|
||||
if (titleInput && title) setInputValue(titleInput, title)
|
||||
if (titleInput && title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) return { success: false, error: '思否未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
@@ -185,17 +192,17 @@ function fillSegmentFaultContent(title, markdown, body) {
|
||||
editor.CodeMirror.setValue(contentToFill)
|
||||
editor.CodeMirror.focus?.()
|
||||
await sleep(500)
|
||||
if (hasExpectedContent(editor.CodeMirror.getWrapperElement?.() || editor)) {
|
||||
if (hasExpectedContent(editor.CodeMirror.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
} else if (editor instanceof HTMLTextAreaElement) {
|
||||
setInputValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (hasExpectedContent(editor)) return { success: true, method: 'textarea' }
|
||||
if (hasExpectedContent(editor.value)) return { success: true, method: 'textarea' }
|
||||
} else if (editor.isContentEditable) {
|
||||
setContentEditableValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (hasExpectedContent(editor)) return { success: true, method: 'contenteditable' }
|
||||
if (hasExpectedContent(editor.textContent)) return { success: true, method: 'contenteditable' }
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -78,6 +78,35 @@ function inspectSohuTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
function fillSohuContent(title, html, plainText) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function waitForCondition(getValue, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
@@ -154,10 +183,14 @@ function fillSohuContent(title, html, plainText) {
|
||||
})
|
||||
}
|
||||
|
||||
function hasExpectedContent(text, imageCount, expectedPlainText) {
|
||||
const expectedTextLength = normalizeText(expectedPlainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
return normalizeText(text).length + imageCount * 12 >= minimumLength
|
||||
function hasExpectedContent(text, imageCount) {
|
||||
const editorText = normalizeComparableText(text)
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
function setEditableHtml(editor, nextHtml, nextPlainText) {
|
||||
@@ -185,7 +218,14 @@ function fillSohuContent(title, html, plainText) {
|
||||
].flatMap(selector => Array.from(document.querySelectorAll(selector))).find(Boolean)
|
||||
})
|
||||
if (!titleInput) return { success: false, error: '未找到搜狐号标题输入框' }
|
||||
if (title) setTitleValue(titleInput, title)
|
||||
if (title) {
|
||||
setTitleValue(titleInput, title)
|
||||
await sleep(200)
|
||||
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
|
||||
if (normalizeText(receivedTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: '搜狐号未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return { success: true, method: 'title-only' }
|
||||
|
||||
@@ -199,7 +239,7 @@ function fillSohuContent(title, html, plainText) {
|
||||
const serialized = ueditor.getContent?.() || ''
|
||||
const text = ueditor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
|
||||
const imageCount = (serialized.match(/<img\b/gi) || []).length
|
||||
if (hasExpectedContent(text, imageCount, plainText)) {
|
||||
if (hasExpectedContent(text, imageCount)) {
|
||||
return { success: true, method: 'ueditor', imageCount }
|
||||
}
|
||||
} catch {}
|
||||
@@ -211,7 +251,7 @@ function fillSohuContent(title, html, plainText) {
|
||||
setEditableHtml(iframeBody, html, plainText)
|
||||
await sleep(500)
|
||||
const imageCount = iframeBody.querySelectorAll('img').length
|
||||
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount, plainText)) {
|
||||
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
|
||||
return { success: true, method: 'iframe', imageCount }
|
||||
}
|
||||
}
|
||||
@@ -221,7 +261,7 @@ function fillSohuContent(title, html, plainText) {
|
||||
setEditableHtml(contentEditor, html, plainText)
|
||||
await sleep(500)
|
||||
const imageCount = contentEditor.querySelectorAll('img').length
|
||||
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount, plainText)) {
|
||||
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
|
||||
return { success: true, method: 'contenteditable', imageCount }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ const TencentCloudPlatform = {
|
||||
autoFillSupported: true,
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
@@ -97,10 +93,43 @@ function inspectTencentCloudTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function fillTencentCloudContent(title, markdown, plainText) {
|
||||
function fillTencentCloudContent(title, markdown, html, plainText) {
|
||||
const contentToFill = markdown || plainText || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || contentToFill)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
@@ -145,6 +174,66 @@ function fillTencentCloudContent(title, markdown, plainText) {
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function selectEditorContents(editor) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function getRichEditorSnapshot(editor) {
|
||||
return {
|
||||
text: normalizeComparableText(editor?.innerText || editor?.textContent),
|
||||
imageCount: editor?.querySelectorAll?.('img').length || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const snapshot = getRichEditorSnapshot(editor)
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
snapshot.text.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => snapshot.text.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || snapshot.imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
function pasteRichText(editor) {
|
||||
if (!html || typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
|
||||
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', plainText || contentToFill)
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
function insertRichTextFallback(editor) {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
if (html) document.execCommand?.('insertHTML', false, html)
|
||||
else document.execCommand?.('insertText', false, plainText || contentToFill)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: plainText || contentToFill,
|
||||
inputType: html ? 'insertFromPaste' : 'insertText',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function getCodeMirror() {
|
||||
const element = document.querySelector('.CodeMirror')
|
||||
return element?.CodeMirror
|
||||
@@ -170,18 +259,47 @@ function fillTencentCloudContent(title, markdown, plainText) {
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到腾讯云开发者社区标题输入框' }
|
||||
if (title) setInputValue(titleInput, title)
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) {
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'#cdc-rich-editor.tiptap.ProseMirror[contenteditable="true"]',
|
||||
'#cdc-rich-editor.tiptap.ProseMirror',
|
||||
'.tiptap.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
], 1200)
|
||||
if (richEditor) {
|
||||
pasteRichText(richEditor)
|
||||
await sleep(350)
|
||||
if (!hasExpectedRichText(richEditor)) {
|
||||
insertRichTextFallback(richEditor)
|
||||
await sleep(350)
|
||||
}
|
||||
if (!hasExpectedRichText(richEditor)) {
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收富文本正文' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
method: 'rich-text',
|
||||
imageCount: getRichEditorSnapshot(richEditor).imageCount,
|
||||
}
|
||||
}
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'])
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 4500)
|
||||
const editor = codeMirror?.CodeMirror || getCodeMirror()
|
||||
if (editor && typeof editor.setValue === 'function') {
|
||||
editor.focus()
|
||||
editor.setValue(contentToFill)
|
||||
await sleep(250)
|
||||
if (normalizeText(editor.getValue?.()) === normalizeText(contentToFill)) {
|
||||
if (normalizeContent(editor.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
|
||||
@@ -191,12 +309,12 @@ function fillTencentCloudContent(title, markdown, plainText) {
|
||||
'textarea[placeholder*="正文"]',
|
||||
'.markdown-editor textarea',
|
||||
'.editor-container textarea',
|
||||
], 4000)
|
||||
], 3000)
|
||||
if (!textarea) return { success: false, error: '未找到腾讯云开发者社区 Markdown 编辑器' }
|
||||
|
||||
setInputValue(textarea, contentToFill)
|
||||
await sleep(250)
|
||||
if (normalizeText(textarea.value) !== normalizeText(contentToFill)) {
|
||||
if (normalizeContent(textarea.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
|
||||
}
|
||||
|
||||
@@ -249,7 +367,7 @@ async function syncTencentCloudContent(tab, content, helpers) {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillTencentCloudContent,
|
||||
args: [content.title, markdown, content.plainText || markdown],
|
||||
args: [content.title, markdown, content.html || '', content.plainText || markdown],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
|
||||
@@ -236,25 +236,53 @@ function renderToutiaoPlainText(markdown) {
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function fillToutiaoContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
function fillToutiaoContent(title, html, plainText) {
|
||||
const contentHtml = html || ''
|
||||
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
@@ -262,7 +290,7 @@ function fillToutiaoContent(title, markdown, body) {
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
@@ -312,39 +340,58 @@ function fillToutiaoContent(title, markdown, body) {
|
||||
'input[placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到今日头条标题输入框' }
|
||||
if (title) setInputValue(titleInput, title)
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '今日头条未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!contentHtml) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
], titleInput)
|
||||
if (!editor) return { success: false, error: '未找到今日头条正文编辑器' }
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const html = renderToutiaoMarkdown(contentToFill)
|
||||
const plainText = renderToutiaoPlainText(contentToFill)
|
||||
dispatchHtmlPaste(editor, html, plainText)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
|
||||
dispatchHtmlPaste(editor, contentHtml, contentText)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: contentText }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
const expectedTextLength = plainText.length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
let editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
const expectedText = htmlToComparableText(contentHtml)
|
||||
const expectedImageCount = Array.from(contentHtml.matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
let editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
let imageCount = editor.querySelectorAll('img').length
|
||||
const hasExpectedContent = () => (
|
||||
(expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
))
|
||||
&& (expectedImageCount === 0 || imageCount >= expectedImageCount)
|
||||
)
|
||||
|
||||
if (editorTextLength + imageCount * 12 < minimumLength && typeof document.execCommand === 'function') {
|
||||
if (!hasExpectedContent() && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, html)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
|
||||
document.execCommand('insertHTML', false, contentHtml)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: contentText }))
|
||||
await sleep(500)
|
||||
editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
imageCount = editor.querySelectorAll('img').length
|
||||
}
|
||||
|
||||
if (editorTextLength + imageCount * 12 < minimumLength) {
|
||||
if (!hasExpectedContent()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '今日头条未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
@@ -407,10 +454,15 @@ async function syncToutiaoContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取今日头条编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillToutiaoContent,
|
||||
args: [content.title, content.markdown, content.body],
|
||||
args: [
|
||||
content.title,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
|
||||
@@ -78,6 +78,35 @@ function inspectWangyihaoTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
function fillWangyihaoContent(title, html, plainText) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors, exclude) {
|
||||
return selectors
|
||||
@@ -156,12 +185,15 @@ function fillWangyihaoContent(title, html, plainText) {
|
||||
return true
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor, expectedPlainText) {
|
||||
const expectedTextLength = normalizeText(expectedPlainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
const editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
function hasExpectedContent(editor) {
|
||||
const editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
const imageCount = editor.querySelectorAll('img').length
|
||||
return editorTextLength + imageCount * 12 >= minimumLength
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
@@ -172,7 +204,14 @@ function fillWangyihaoContent(title, html, plainText) {
|
||||
'[contenteditable="true"][placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到网易号标题输入框' }
|
||||
if (title) setTitleValue(titleInput, title)
|
||||
if (title) {
|
||||
setTitleValue(titleInput, title)
|
||||
await sleep(200)
|
||||
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
|
||||
if (normalizeText(receivedTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: '网易号未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return { success: true, method: 'title-only' }
|
||||
|
||||
@@ -194,7 +233,7 @@ function fillWangyihaoContent(title, html, plainText) {
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
if (!hasExpectedContent(editor, plainText) && typeof document.execCommand === 'function') {
|
||||
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, html)
|
||||
@@ -206,7 +245,7 @@ function fillWangyihaoContent(title, html, plainText) {
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
if (!hasExpectedContent(editor, plainText)) {
|
||||
if (!hasExpectedContent(editor)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '网易号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
|
||||
@@ -81,22 +81,41 @@ 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, '')
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
function markdownToComparableText(value) {
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||
.replace(/<[^>]+>/g, ''))
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
|
||||
observer.disconnect()
|
||||
@@ -105,7 +124,7 @@ function fillZhihuContent(title, markdown, body) {
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
@@ -169,7 +188,13 @@ function fillZhihuContent(title, markdown, body) {
|
||||
if (!titleInput) {
|
||||
return { success: false, error: '未找到知乎标题输入框' }
|
||||
}
|
||||
if (title) setInputValue(titleInput, title)
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '知乎未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.public-DraftEditor-content[contenteditable="true"]',
|
||||
@@ -177,7 +202,7 @@ function fillZhihuContent(title, markdown, body) {
|
||||
'.DraftEditor-root [contenteditable="true"]',
|
||||
'.WriteIndex-content [contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
], titleInput)
|
||||
if (!editor) {
|
||||
return { success: false, error: '未找到知乎正文编辑器' }
|
||||
}
|
||||
@@ -203,14 +228,24 @@ function fillZhihuContent(title, markdown, body) {
|
||||
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) {
|
||||
const expectedText = markdownToComparableText(contentToFill)
|
||||
const editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
const expectedImageCount = Array.from(contentToFill.matchAll(/!\[[^\]]*]\([^)]*\)/g)).length
|
||||
const editorImageCount = editor.querySelectorAll('img').length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || editorImageCount >= expectedImageCount
|
||||
if (!receivedText || !receivedImages) {
|
||||
return {
|
||||
success: false,
|
||||
error: '知乎未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
|
||||
@@ -17,7 +17,7 @@ import { detectTencentCloudUser } from './platforms/tencentcloud.js'
|
||||
import { detectQianfanUser } from './platforms/qianfan.js'
|
||||
import { detectTwitterUser } from './platforms/twitter.js'
|
||||
import { detectBilibiliUser } from './platforms/bilibili.js'
|
||||
import { detectCTO51User } from './platforms/cto51.js'
|
||||
import { detectCto51User } from './platforms/cto51.js'
|
||||
import { detectJianshuUser } from './platforms/jianshu.js'
|
||||
import { detectSegmentFaultUser } from './platforms/segmentfault.js'
|
||||
import { detectInfoQUser } from './platforms/infoq.js'
|
||||
@@ -46,7 +46,7 @@ const PLATFORM_DETECTORS = {
|
||||
qianfan: detectQianfanUser,
|
||||
twitter: detectTwitterUser,
|
||||
bilibili: detectBilibiliUser,
|
||||
cto51: detectCTO51User,
|
||||
'51cto': detectCto51User,
|
||||
jianshu: detectJianshuUser,
|
||||
segmentfault: detectSegmentFaultUser,
|
||||
infoq: detectInfoQUser,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,35 +1,135 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getCurrentAccountScopes(html) {
|
||||
return Array.from(html.matchAll(
|
||||
/"(?:currentUser|current_user|userInfo|user_info|account|profile)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,4000}\})/g,
|
||||
)).map(match => match[1])
|
||||
}
|
||||
|
||||
function readJsonField(scopes, fields) {
|
||||
for (const scope of scopes) {
|
||||
for (const field of fields) {
|
||||
const match = scope.match(new RegExp(`"${field}"\\s*:\\s*(?:"([^"]+)"|(\\d+))`))
|
||||
const value = normalizeString(match?.[1] || match?.[2])
|
||||
if (value) return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readHtmlAttribute(html, attributes) {
|
||||
for (const attribute of attributes) {
|
||||
const match = html.match(new RegExp(`${attribute}=["']([^"']+)["']`, 'i'))
|
||||
const value = normalizeString(match?.[1])
|
||||
if (value) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readAvatarUid(html) {
|
||||
const avatarSources = Array.from(html.matchAll(
|
||||
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
|
||||
)).map(match => match[1])
|
||||
|
||||
for (const source of avatarSources) {
|
||||
const match = source.match(/[?&](?:uid|user_id|userId)=(\d+)(?:[&#]|$)/)
|
||||
if (match?.[1]) return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readProfileUid(html) {
|
||||
return html.match(/(?:https?:\/\/blog\.51cto\.com)?\/u_(\d+)(?:[/?#"'])/i)?.[1]
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return normalizeString(String(value || '').replace(/<[^>]+>/g, '').replace(/ /gi, ' '))
|
||||
}
|
||||
|
||||
/**
|
||||
* 51CTO platform detection logic (same approach as 爱贝壳)
|
||||
* Fetch https://home.51cto.com/space via offscreen document,
|
||||
* parse HTML with DOMParser to extract avatar, uid, nickname.
|
||||
* 51CTO 账号绑定必须拿到创作者页中的稳定 UID。
|
||||
* 昵称和头像只用于展示,不能独立作为发布任务的账号标识。
|
||||
*/
|
||||
export async function detectCTO51User() {
|
||||
try {
|
||||
console.log('[COSE] 51CTO Detection: Starting (offscreen)')
|
||||
function parseCto51Account(html) {
|
||||
if (typeof html !== 'string') {
|
||||
return { platformUid: undefined, username: '', avatar: '' }
|
||||
}
|
||||
|
||||
if (typeof globalThis.__coseDetectCto51 === 'function') {
|
||||
const result = await globalThis.__coseDetectCto51()
|
||||
if (result && result.loggedIn) {
|
||||
let avatar = result.avatar || ''
|
||||
if (avatar && avatar.startsWith('http') && avatar.includes('51cto.com')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://home.51cto.com/')
|
||||
}
|
||||
console.log('[COSE] 51CTO: Logged in:', result.username)
|
||||
return { loggedIn: true, username: result.username || '', avatar }
|
||||
}
|
||||
// Pass through debug info if present
|
||||
if (result && result._debug) {
|
||||
console.log('[COSE] 51CTO: Not logged in, debug:', JSON.stringify(result._debug))
|
||||
return { loggedIn: false, _debug: result._debug }
|
||||
}
|
||||
}
|
||||
const scopes = getCurrentAccountScopes(html)
|
||||
const avatar = readHtmlAttribute(html, ['data-avatar', 'data-avatar-url'])
|
||||
|| html.match(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/i)?.[1]
|
||||
|| ''
|
||||
const profileName = stripHtml(
|
||||
html.match(/<a\b[^>]*href=["'][^"']*\/u_\d+[^"']*["'][^>]*>([\s\S]{1,160}?)<\/a>/i)?.[1],
|
||||
)
|
||||
|
||||
console.log('[COSE] 51CTO: Not logged in')
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
console.error('[COSE] 51CTO Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
return {
|
||||
platformUid: readHtmlAttribute(html, [
|
||||
'data-current-user-id',
|
||||
'data-current-userid',
|
||||
'data-user-id',
|
||||
'data-userid',
|
||||
'data-uid',
|
||||
])
|
||||
|| readJsonField(scopes, ['uid', 'userId', 'user_id', 'id'])
|
||||
|| readAvatarUid(html)
|
||||
|| readProfileUid(html),
|
||||
username: readHtmlAttribute(html, ['data-nickname', 'data-user-name', 'data-username'])
|
||||
|| readJsonField(scopes, ['nickname', 'userName', 'username', 'name'])
|
||||
|| profileName
|
||||
|| '',
|
||||
avatar,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在用户当前 Chrome 的 51CTO 站点上下文中读取个人空间页面。
|
||||
* 不读取、复制或上传 Cookie,浏览器仅向原站携带既有登录态。
|
||||
*/
|
||||
async function detectCto51User() {
|
||||
try {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '51CTO 账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://home.51cto.com/',
|
||||
'https://home.51cto.com/space',
|
||||
{ responseType: 'text', method: 'GET' },
|
||||
)
|
||||
const account = parseCto51Account(response?.body)
|
||||
let finalUrl
|
||||
try {
|
||||
finalUrl = new URL(response?.url || '')
|
||||
} catch {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| finalUrl.protocol !== 'https:'
|
||||
|| finalUrl.hostname !== 'home.51cto.com'
|
||||
|| !/^\/space\/?$/.test(finalUrl.pathname)
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '51CTO 账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectCto51User, parseCto51Account }
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* OSChina platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check oscid cookie existence as login indicator (MV3 service worker compatible)
|
||||
* 2. Best-effort: fetch user info via API for username and avatar
|
||||
* 账号绑定只接受开源中国账号接口返回的稳定 ID。
|
||||
* 昵称和头像仅用于展示,不能作为发布任务的账号标识。
|
||||
*/
|
||||
export async function detectOSChinaUser() {
|
||||
function parseOSChinaAccount(response) {
|
||||
const user = response?.result?.userVo && typeof response.result.userVo === 'object'
|
||||
? response.result.userVo
|
||||
: undefined
|
||||
|
||||
return {
|
||||
platformUid: normalizeString(user?.id)
|
||||
|| normalizeString(user?.uid)
|
||||
|| normalizeString(user?.userId)
|
||||
|| normalizeString(user?.user_id),
|
||||
username: normalizeString(user?.name)
|
||||
|| normalizeString(user?.nickname)
|
||||
|| normalizeString(user?.userName)
|
||||
|| '',
|
||||
avatar: normalizeString(user?.portraitUrl)
|
||||
|| normalizeString(user?.avatar)
|
||||
|| normalizeString(user?.avatarUrl)
|
||||
|| '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在用户当前 Chrome 的开源中国站点上下文中调用账号接口。
|
||||
* 不读取、复制或上传 Cookie,浏览器只向原站自动携带已有登录态。
|
||||
*/
|
||||
async function detectOSChinaUser() {
|
||||
try {
|
||||
// Check oscid cookie as login indicator
|
||||
const oscidCookie = await chrome.cookies.get({ url: 'https://www.oschina.net', name: 'oscid' })
|
||||
if (!oscidCookie || !oscidCookie.value) {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '开源中国账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://my.oschina.net/',
|
||||
'https://apiv1.oschina.net/oschinapi/user/myDetails',
|
||||
{
|
||||
responseType: 'json',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
const account = parseOSChinaAccount(response?.body)
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| response?.body?.success !== true
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// Collect all cookies for API request
|
||||
const cookies = await chrome.cookies.getAll({ domain: '.oschina.net' })
|
||||
const wwwCookies = await chrome.cookies.getAll({ url: 'https://www.oschina.net' })
|
||||
const apiCookies = await chrome.cookies.getAll({ url: 'https://apiv1.oschina.net' })
|
||||
const allCookies = [...cookies, ...wwwCookies, ...apiCookies]
|
||||
const seen = new Set()
|
||||
const uniqueCookies = allCookies.filter(c => {
|
||||
const key = `${c.name}=${c.value}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
const cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
|
||||
// Best-effort: try to get username and avatar via API
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
let userId = ''
|
||||
try {
|
||||
const response = await fetch('https://apiv1.oschina.net/oschinapi/user/myDetails', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Cookie: cookieStr,
|
||||
},
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data?.success && data?.result?.userVo) {
|
||||
username = data.result.userVo.name || ''
|
||||
avatar = data.result.userVo.portraitUrl || ''
|
||||
userId = String(data.result.userVo.id || '')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] OSChina: API fetch failed, using cookie-only detection')
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '开源中国账号检测失败',
|
||||
}
|
||||
|
||||
if (avatar && (avatar.includes('oschina.net') || avatar.includes('oscimg'))) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://www.oschina.net/')
|
||||
}
|
||||
|
||||
// Store userId for sync URL construction
|
||||
if (userId) {
|
||||
try {
|
||||
await chrome.storage.local.set({ oschina_userId: userId })
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar, userId }
|
||||
} catch (e) {
|
||||
console.error('[COSE] OSChina Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
export { detectOSChinaUser, parseOSChinaAccount }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function readField(html, fields) {
|
||||
for (const field of fields) {
|
||||
const match = html.match(new RegExp(`["']?${field}["']?\\s*[:=]\\s*(?:["']([^"']+)["']|(\\d+))`))
|
||||
const value = normalizeString(match?.[1] || match?.[2])
|
||||
if (value) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 企鹅号账号绑定只接受编辑器页面当前账号数据中的 userID。
|
||||
* 昵称、作者名和头像只用于显示,绝不能作为发布任务的账号标识。
|
||||
*/
|
||||
function parseQiehaoAccount(html) {
|
||||
if (typeof html !== 'string') {
|
||||
return { platformUid: undefined, username: '', avatar: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
platformUid: readField(html, ['mediaId', 'userID', 'userId']),
|
||||
username: readField(html, ['mediaName', 'nickName', 'nickname', 'userName', 'name']) || '',
|
||||
avatar: readField(html, ['header', 'avatarUrl', 'avatar']) || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅通过用户当前 Chrome 的企鹅号页面上下文读取已登录编辑器。
|
||||
* 浏览器会自行携带原站登录态;扩展不读取、复制或上传 Cookie、Token、CSRF。
|
||||
*/
|
||||
async function detectQiehaoUser() {
|
||||
try {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '企鹅号账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://om.qq.com/',
|
||||
'https://om.qq.com/main/creation/article',
|
||||
{
|
||||
responseType: 'text',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
const account = parseQiehaoAccount(response?.body)
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| !response?.url?.startsWith('https://om.qq.com/main/creation/article')
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '企鹅号账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectQiehaoUser, parseQiehaoAccount }
|
||||
@@ -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')
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
} from './configs.js'
|
||||
import { detectCsdnUser } from './platforms/csdn.js'
|
||||
import { detectAliyunUser } from './platforms/aliyun.js'
|
||||
import { detectCto51User } from './platforms/cto51.js'
|
||||
import { detectInfoQUser } from './platforms/infoq.js'
|
||||
import { detectJianshuUser } from './platforms/jianshu.js'
|
||||
import { detectOSChinaUser } from './platforms/oschina.js'
|
||||
import { detectQiehaoUser } from './platforms/qiehao.js'
|
||||
import { detectSegmentFaultUser } from './platforms/segmentfault.js'
|
||||
import { detectSohuUser } from './platforms/sohu.js'
|
||||
import { detectTencentCloudUser } from './platforms/tencentcloud.js'
|
||||
@@ -43,6 +46,9 @@ const SUPPORTED_PLATFORM_DETECTORS = {
|
||||
infoq: detectInfoQUser,
|
||||
tencentcloud: detectTencentCloudUser,
|
||||
aliyun: detectAliyunUser,
|
||||
'51cto': detectCto51User,
|
||||
oschina: detectOSChinaUser,
|
||||
qiehao: detectQiehaoUser,
|
||||
}
|
||||
|
||||
function normalizePlatformUid(value) {
|
||||
|
||||
@@ -28,13 +28,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_DETECT_CTO51') {
|
||||
handleDetectCto51()
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_DETECT_CNBLOGS') {
|
||||
handleDetectCnblogs()
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
@@ -121,48 +114,6 @@ async function handleApiFetch(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 51CTO detection: fetch home.51cto.com/space and parse with DOMParser.
|
||||
* Same approach as 爱贝壳 extension - runs in document context (offscreen).
|
||||
*/
|
||||
async function handleDetectCto51() {
|
||||
try {
|
||||
const resp = await fetch('https://home.51cto.com/space', {
|
||||
credentials: 'include',
|
||||
})
|
||||
const html = await resp.text()
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
|
||||
// Avatar: <img alt="头像">
|
||||
const avatarEl = doc.querySelector("img[alt='头像']")
|
||||
const avatar = avatarEl ? avatarEl.getAttribute('src') : ''
|
||||
|
||||
// UID from avatar URL: uid=(\d+)
|
||||
let uid = ''
|
||||
if (avatar) {
|
||||
const m = avatar.match(/uid=(\d+)/)
|
||||
if (m) uid = m[1]
|
||||
}
|
||||
|
||||
// Nickname: div.name > a
|
||||
const nameEl = doc.querySelector('div.name > a')
|
||||
const username = nameEl ? nameEl.textContent.trim() : ''
|
||||
|
||||
if (!username && !uid) {
|
||||
// Return debug info to help diagnose
|
||||
const title = doc.querySelector('title')?.textContent || ''
|
||||
return {
|
||||
loggedIn: false,
|
||||
_debug: { status: resp.status, url: resp.url, htmlLen: html.length, title },
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar, uid }
|
||||
} catch (e) {
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cnblogs detection: fetch account.cnblogs.com/user/userinfo in document context.
|
||||
* Cookies are sent automatically (unlike service worker fetch which strips Cookie headers).
|
||||
|
||||
+6
-1
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AiToEarn - 内容营销助手",
|
||||
"version": "1.15.0",
|
||||
"version": "1.19.6",
|
||||
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"alarms",
|
||||
"clipboardRead",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
@@ -20,6 +21,8 @@
|
||||
"http://localhost:6061/*",
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*",
|
||||
"http://localhost:3002/*",
|
||||
"https://wxapi.frp.it1024.cc/*",
|
||||
"https://*.csdn.net/*",
|
||||
"https://*.juejin.cn/*",
|
||||
"https://*.jianshu.com/*",
|
||||
@@ -44,6 +47,8 @@
|
||||
"https://*.baijiahao.baidu.com/*",
|
||||
"https://*.163.com/*",
|
||||
"https://*.cloud.tencent.com/*",
|
||||
"https://om.qq.com/*",
|
||||
"https://page.om.qq.com/*",
|
||||
"https://*.medium.com/*",
|
||||
"https://*.sohu.com/*",
|
||||
"https://*.aliyun.com/*",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs",
|
||||
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs && node scripts/test-browser-agent.mjs",
|
||||
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1",
|
||||
"release:verify": "node scripts/verify-release.mjs"
|
||||
}
|
||||
|
||||
+66
-1
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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,
|
||||
new Set([
|
||||
'https://wxapi.frp.it1024.cc',
|
||||
'http://localhost:3002',
|
||||
]),
|
||||
)
|
||||
assert.equal(normalizeApiBaseUrl('https://wxapi.frp.it1024.cc/'), 'https://wxapi.frp.it1024.cc')
|
||||
assert.equal(normalizeApiBaseUrl('http://localhost:3002'), 'http://localhost:3002')
|
||||
assert.throws(
|
||||
() => normalizeApiBaseUrl('https://api.example.com'),
|
||||
/已配置的 AiToEarn API 地址/,
|
||||
)
|
||||
assert.throws(
|
||||
() => normalizeApiBaseUrl('https://wxapi.frp.it1024.cc/v2'),
|
||||
/已配置的 AiToEarn API 地址/,
|
||||
)
|
||||
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: '存草稿',
|
||||
})
|
||||
assert.deepEqual(getPlatformAction('qiehao', 'publish'), {
|
||||
selector: 'button.omui-button.omui-button--primary',
|
||||
text: '发布',
|
||||
})
|
||||
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,
|
||||
)
|
||||
|
||||
const originalQiehaoMessageHandler = globalThis.chrome.runtime.sendMessage
|
||||
const originalQiehaoScriptHandler = globalThis.chrome.scripting.executeScript
|
||||
const qiehaoClickedActions = []
|
||||
const qiehaoActionArguments = []
|
||||
globalThis.chrome.runtime.sendMessage = async (message) => {
|
||||
sentMessages.push(message)
|
||||
if (message.type === 'GET_PLATFORM_ACCOUNT') {
|
||||
return {
|
||||
success: true,
|
||||
platformUid: '24973524',
|
||||
displayName: '企鹅号创作者973524',
|
||||
}
|
||||
}
|
||||
if (message.type === 'OPEN_PLATFORM_TASK') {
|
||||
return {
|
||||
success: true,
|
||||
status: 'filled',
|
||||
tabId: 202,
|
||||
url: 'https://om.qq.com/main/creation/article',
|
||||
message: '企鹅号编辑器已填充',
|
||||
}
|
||||
}
|
||||
if (message.type === 'INSPECT_PLATFORM_TASK') {
|
||||
return {
|
||||
success: true,
|
||||
status: 'published',
|
||||
url: 'https://page.om.qq.com/page/ABCDEF123.html',
|
||||
message: '已确认企鹅号文章公开地址',
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected message: ${message.type}`)
|
||||
}
|
||||
globalThis.chrome.scripting.executeScript = async ({ func, args }) => {
|
||||
scriptExecutionCount += 1
|
||||
qiehaoActionArguments.push(args)
|
||||
const buttons = [
|
||||
{
|
||||
textContent: '定时发布',
|
||||
disabled: false,
|
||||
getAttribute: () => null,
|
||||
click: () => qiehaoClickedActions.push('定时发布'),
|
||||
},
|
||||
{
|
||||
textContent: '发布',
|
||||
disabled: false,
|
||||
getAttribute: () => null,
|
||||
click: () => qiehaoClickedActions.push('发布'),
|
||||
},
|
||||
]
|
||||
globalThis.document = {
|
||||
querySelectorAll: (selector) => {
|
||||
assert.equal(selector, 'button.omui-button.omui-button--primary')
|
||||
return buttons
|
||||
},
|
||||
}
|
||||
try {
|
||||
return [{ result: func(...args) }]
|
||||
}
|
||||
finally {
|
||||
delete globalThis.document
|
||||
}
|
||||
}
|
||||
|
||||
sentMessages.length = 0
|
||||
const qiehaoPublishExecution = await executeClaimedTask({
|
||||
platform: 'qiehao',
|
||||
platformUid: '24973524',
|
||||
title: '企鹅号精确按钮验证',
|
||||
body: '<p>正文</p>',
|
||||
handoff: {
|
||||
publishMode: 'publish',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
assert.equal(qiehaoPublishExecution.report, true)
|
||||
assert.equal(qiehaoPublishExecution.result.status, 'published')
|
||||
assert.equal(qiehaoPublishExecution.result.platformUid, '24973524')
|
||||
assert.deepEqual(qiehaoActionArguments, [[
|
||||
'button.omui-button.omui-button--primary',
|
||||
'发布',
|
||||
]])
|
||||
assert.deepEqual(qiehaoClickedActions, ['发布'])
|
||||
|
||||
globalThis.chrome.runtime.sendMessage = originalQiehaoMessageHandler
|
||||
globalThis.chrome.scripting.executeScript = originalQiehaoScriptHandler
|
||||
|
||||
delete globalThis.chrome
|
||||
|
||||
const markdownTaskContent = toTaskContent({
|
||||
title: '浏览器交接测试',
|
||||
body: '# 标题\n\n- 列表项\n\n',
|
||||
})
|
||||
assert.equal(markdownTaskContent.markdown, '# 标题\n\n- 列表项\n\n')
|
||||
assert.match(markdownTaskContent.html, /<h1>标题<\/h1>/)
|
||||
assert.match(markdownTaskContent.html, /<ul><li>列表项<\/li><\/ul>/)
|
||||
assert.match(markdownTaskContent.html, /<img src="https:\/\/example\.com\/image\.png" alt="配图" \/>/)
|
||||
assert.equal(markdownTaskContent.plainText.includes('#'), false)
|
||||
|
||||
const htmlTaskContent = toTaskContent({
|
||||
title: '富文本交接测试',
|
||||
body: '<p>正文</p><img src="https://example.com/image.png" alt="配图" />',
|
||||
})
|
||||
assert.equal(htmlTaskContent.html, '<p>正文</p><img src="https://example.com/image.png" alt="配图" />')
|
||||
assert.match(htmlTaskContent.plainText, /正文/)
|
||||
assert.equal(htmlTaskContent.plainText.includes('<img'), false)
|
||||
|
||||
const agentSource = await readFile(
|
||||
new URL('../distribution/browser-agent/index.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(agentSource, /chrome\.cookies|Cookie\s*:/)
|
||||
assert.match(agentSource, /x-aitoearn-browser-agent/)
|
||||
assert.match(agentSource, /publication_uncertain/)
|
||||
assert.match(agentSource, /BROWSER_AGENT_PLATFORM_IDS/)
|
||||
|
||||
console.log('Browser agent validation passed')
|
||||
+1005
-6
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user