Compare commits

..

17 Commits

Author SHA1 Message Date
admin ef64467f6b fix(extension): 加固多平台内容填充完整性 2026-08-17 20:12:03 +08:00
admin c2d2a6c749 fix(extension): 加固掘金内容填充 2026-08-17 18:53:40 +08:00
admin 537df968a0 fix(extension): 补齐注入函数隔离边界 2026-08-17 11:48:35 +08:00
admin dd9e628ac2 fix(extension): 修复三平台注入填充 2026-08-17 11:23:41 +08:00
admin 679e949703 build(extension): 生成可复现发布包 2026-08-17 08:25:37 +08:00
admin 984d646e1d fix(extension): 修复多平台账号识别 2026-08-17 01:12:21 +08:00
admin 52e8f7f4a0 feat(extension): 支持声明式发布 Provider 快照 2026-08-16 19:41:45 +08:00
admin da8ad24f14 feat(extension): expand browser agent platform coverage 2026-08-16 17:50:30 +08:00
admin 6561378bc1 fix(extension): 适配 CSDN 当前标题编辑器 2026-08-16 17:23:54 +08:00
admin be650f6fab fix(extension): 支持腾讯云富文本编辑器交接 2026-08-16 17:10:31 +08:00
admin 8a8dcb0023 feat(extension): add browser agent pairing and qiehao handoff 2026-08-16 15:49:05 +08:00
admin 874455e5f2 feat(extension): add OSChina browser handoff 2026-08-16 12:35:23 +08:00
admin ae62ff02b6 docs(extension): update browser provider coverage 2026-08-16 12:06:25 +08:00
admin 5821c5711b feat(extension): add 51cto browser handoff 2026-08-16 12:01:07 +08:00
admin 6aea2f6810 feat(extension): 接入阿里云开发者浏览器交接 2026-08-16 11:21:10 +08:00
admin 1a94449988 chore(extension): verify released archive 2026-08-16 10:59:17 +08:00
admin 4f454f0ada feat(extension): add CSDN and SegmentFault handoff 2026-08-16 10:50:18 +08:00
45 changed files with 6208 additions and 865 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.zip binary
+9 -5
View File
@@ -8,15 +8,19 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动桥接,不冒充旧官方扩展的完整能力。
- 复用浏览器现有登录状态检测内容平台账号。
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
- 扩展弹窗从 COSE 适配器动态读取已完成受控填充验证的平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
## Browser Provider
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入掘金、简书、知乎、今日头条、百家号、网易号、搜狐号InfoQ:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID;账号检测不读取、复制或上传平台 Cookie
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`
阿里云开发者社区、腾讯云开发者社区和 CSDN 的遗留脚本尚未具备稳定 UID、编辑器回读和草稿状态三项证据。扩展会明确阻止自动填充,不会伪报“已同步”;这些平台可以继续使用 AiToEarn 的复制发布稿手动发布,待独立验收完成后再开放 Browser Provider
扩展支持服务端随发布任务下发经过签名安装、Schema 校验并固化版本身份的声明式 Browser Provider 快照。插件只能覆盖核心十五个平台的标题、正文与第一层草稿/发布按钮定义;平台 URL、账号检测和最终结果判断仍由扩展核心维护。扩展会再次严格校验平台、选择器、编辑器类型、内容格式、源提交和内容哈希,写入后回读标题、正文长度和图片数量。配置漂移或回读失败时保留编辑器并返回明确失败,不执行仓库代码,也不会把未知按钮当作核心动作继续点击
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id``www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id``www.infoq.cn/article/:id` 才记为已发布;知乎 `/write/:id`、今日头条和百家号带草稿标识的编辑器地址,以及网易号、搜狐号或 InfoQ 页面明确提示草稿保存成功时才记为草稿。InfoQ 的 `/draft/:id` 在未确认保存前只显示为已填充,无法确定时进入“发布结果待确认”,不会伪报成功
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`、阿里云只有 `developer.aliyun.com/article/:id`、思否只有 `segmentfault.com/a/:id`、51CTO 只有 `blog.51cto.com/u_:uid/:id` 才记为已发布;带草稿标识的编辑器地址,以及网易号、搜狐号、InfoQ、51CTO 或开源中国页面明确提示草稿保存成功时才记为草稿。开源中国的公开文章 URL 尚未在真实创作者账号上验收,因此未知页面会进入“发布结果待确认”,不会伪报成功。
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
@@ -54,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
+12 -2
View File
@@ -1,5 +1,7 @@
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';
const CAPTURE_URL_MESSAGE = 'CAPTURE_URL';
@@ -211,11 +213,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type !== PREPARE_BATCH_MESSAGE) return false;
const platformIds = Array.isArray(request.platformIds)
const requestedPlatformIds = Array.isArray(request.platformIds)
? request.platformIds.filter(platformId => typeof platformId === 'string' && platformId.trim())
: [];
const platformIds = [...new Set(requestedPlatformIds)]
.filter(platformId => AUTO_FILL_PLATFORM_IDS.has(platformId));
if (platformIds.length === 0 || !request.content || typeof request.content !== 'object') {
sendResponse({ success: false, error: '缺少目标平台或分发内容' });
sendResponse({
success: false,
error: requestedPlatformIds.length > 0
? '所选平台暂未开放受控自动填充,请使用对应平台的复制发布稿。'
: '缺少目标平台或分发内容',
});
return false;
}
+15
View File
@@ -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}`);
}
+4
View File
@@ -59,6 +59,10 @@
tabId,
expectedPlatformUid,
}),
pairBrowserAgent: payload => request('pairBrowserAgent', payload),
getBrowserAgentStatus: () => request('getBrowserAgentStatus'),
pollBrowserAgentNow: () => request('pollBrowserAgentNow'),
disconnectBrowserAgent: () => request('disconnectBrowserAgent'),
},
});
+728
View File
@@ -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,
}
+191 -202
View File
@@ -1,8 +1,17 @@
// 平台配置
import { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS } from './core/platforms/index.js'
import {
AUTO_FILL_PLATFORM_IDS,
INSPECT_HANDLERS,
PLATFORMS,
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 { convertAvatarToBase64 } from './detection/src/utils.js'
import { detectSupportedPlatformUser } from './detection/src/supported-platforms.js'
// [DISABLED] import { fillAlipayOpenContent } from '@cose/core/src/platforms/alipayopen.js'
// ===== Offscreen helper =====
@@ -237,40 +246,94 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
globalThis.__coseTabContextFetch = tabContextFetch
/**
* 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.
* CSDN 的创作者资料接口要求使用编辑器页面自身的签名 HTTP 客户端。
* 这里仅在当前 Chrome 的 CSDN 页面上下文中执行该客户端,不导出 Cookie、Token 或 CSRF。
*/
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
async function getCsdnProfileFromSiteContext() {
const siteUrl = 'https://editor.csdn.net/md/'
let createdTabId = null
try {
const tabs = await chrome.tabs.query({ url: 'https://editor.csdn.net/md/*' })
let tab = tabs.find(candidate => candidate.id && !candidate.discarded)
if (!tab) {
tab = await chrome.tabs.create({ url: siteUrl, active: false })
createdTabId = tab.id
}
if (!tab?.id) return null
await waitForTab(tab.id, 30000)
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 || '',
}
}
}
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
const visibleProfile = readVisibleProfile()
const deadline = Date.now() + 12000
while (Date.now() < deadline) {
if (typeof window.csdn?.httpGateway === 'function') break
await new Promise(resolve => setTimeout(resolve, 250))
}
if (typeof window.csdn?.httpGateway !== 'function') {
if (visibleProfile) {
return { status: 200, body: { data: visibleProfile }, source: 'editor_dom' }
}
return { status: 0, error: 'CSDN 编辑器签名请求客户端未加载' }
}
try {
const client = window.csdn.httpGateway()
const body = await client.get(
'https://bizapi.csdn.net/blog-console-api/v3/editor/getBaseInfo',
)
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,
body: response?.data || null,
error: error instanceof Error ? error.message : 'CSDN 账号资料请求失败',
}
}
},
world: 'MAIN',
})
return result?.[0]?.result || null
} catch (error) {
console.log('[COSE] CSDN profile detection failed:', error?.message || error)
return null
} finally {
if (createdTabId) {
try {
await chrome.tabs.remove(createdTabId)
} catch {}
}
}
return null
}
globalThis.__coseDetectCto51 = detectCto51ViaOffscreen
globalThis.__coseCsdnProfile = getCsdnProfileFromSiteContext
/**
* Cnblogs detection via offscreen document.
@@ -492,13 +555,17 @@ async function handleMessage(request, sender) {
console.log(`[COSE] handleMessage received type: ${request.type}`, request)
switch (request.type) {
case 'GET_PLATFORMS':
return { platforms: PLATFORMS }
case 'CHECK_PLATFORM_STATUS':
return { status: await checkAllPlatforms(request.platforms || PLATFORMS) }
case 'CHECK_PLATFORM_STATUS_PROGRESSIVE':
return { platforms: PLATFORMS.filter(platform => platform.autoFillSupported === true) }
case 'CHECK_PLATFORM_STATUS': {
const platforms = getRequestedAutoFillPlatforms(request.platforms)
return { status: await checkAllPlatforms(platforms) }
}
case 'CHECK_PLATFORM_STATUS_PROGRESSIVE': {
// 渐进式检测:每个平台检测完成后立即返回结果
checkAllPlatformsProgressive(request.platforms || PLATFORMS, sender.tab?.id)
return { started: true, total: (request.platforms || PLATFORMS).length }
const platforms = getRequestedAutoFillPlatforms(request.platforms)
checkAllPlatformsProgressive(platforms, sender.tab?.id)
return { started: true, total: platforms.length }
}
case 'GET_PLATFORM_ACCOUNT':
return await getPlatformAccount(request.platformId)
case 'OPEN_PLATFORM_TASK':
@@ -506,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(
@@ -521,46 +589,44 @@ async function handleMessage(request, sender) {
case 'SYNC_TO_PLATFORM':
return await syncToPlatform(request.platformId, request.content, request.options)
case 'CACHE_USER_INFO':
// 缓存用户信息
if (request.platform === 'xiaohongshu' && request.userInfo) {
await chrome.storage.local.set({ xiaohongshu_user: request.userInfo })
console.log('[COSE] 小红书用户信息已缓存:', request.userInfo.username)
} else if (request.platform === 'alipayopen' && request.userInfo) {
await chrome.storage.local.set({ alipayopen_user: request.userInfo })
console.log('[COSE] 支付宝用户信息已缓存:', request.userInfo.username)
} else if (request.platform === 'huaweicloud' && request.userInfo) {
const hwcInfo = { ...request.userInfo }
if (hwcInfo.avatar && hwcInfo.avatar.startsWith('http')) {
hwcInfo.avatar = await convertAvatarToBase64(
hwcInfo.avatar,
'https://bbs.huaweicloud.com/'
)
}
await chrome.storage.local.set({ huaweicloud_user: hwcInfo })
console.log('[COSE] 华为云用户信息已缓存:', hwcInfo.username)
} else if (request.platform === 'huaweidev' && request.userInfo) {
const hwdInfo = { ...request.userInfo }
if (hwdInfo.avatar && hwdInfo.avatar.startsWith('http')) {
hwdInfo.avatar = await convertAvatarToBase64(
hwdInfo.avatar,
'https://developer.huawei.com/'
)
}
await chrome.storage.local.set({ huaweidev_user: hwdInfo })
console.log('[COSE] 华为开发者用户信息已缓存:', hwdInfo.username)
if (request.platform !== 'wechat' || !request.userInfo?.platformUid) {
return { success: false, error: '该平台未开放受控账号缓存' }
}
await chrome.storage.local.set({
wechat_user: {
loggedIn: true,
platformUid: String(request.userInfo.platformUid),
username: request.userInfo.username || '',
avatar: request.userInfo.avatar || '',
cachedAt: Date.now(),
},
})
return { success: true }
default:
return { error: 'Unknown message type' }
}
}
function getRequestedAutoFillPlatforms(requestedPlatforms) {
const requestedIds = Array.isArray(requestedPlatforms)
? new Set(
requestedPlatforms
.map(platform => (typeof platform === 'string' ? platform : platform?.id))
.filter(platformId => typeof platformId === 'string' && platformId.trim()),
)
: undefined
return PLATFORMS.filter(platform => {
return platform.autoFillSupported === true
&& (!requestedIds || requestedIds.has(platform.id))
})
}
// 检查所有平台登录状态
async function checkAllPlatforms(platforms) {
const status = {}
try {
// 过滤掉无效的平台配置
const validPlatforms = (platforms || []).filter(p => p && p.id)
const validPlatforms = getRequestedAutoFillPlatforms(platforms)
const results = await Promise.allSettled(
validPlatforms.map(async platform => {
try {
@@ -584,7 +650,7 @@ async function checkAllPlatforms(platforms) {
// 渐进式检查所有平台登录状态(每个平台完成后立即返回结果)
async function checkAllPlatformsProgressive(platforms, tabId) {
const validPlatforms = (platforms || []).filter(p => p && p.id)
const validPlatforms = getRequestedAutoFillPlatforms(platforms)
let completed = 0
const total = validPlatforms.length
@@ -649,14 +715,12 @@ async function checkAllPlatformsProgressive(platforms, tabId) {
}
}
import { detectUser } from './detection/index.js'
// 检查单个平台登录状态
async function checkPlatformLogin(platform) {
if (!platform || !platform.id) {
if (!platform || !AUTO_FILL_PLATFORM_IDS.has(platform.id)) {
return { loggedIn: false, error: '无效的平台配置' }
}
return await detectUser(platform.id)
return await detectSupportedPlatformUser(platform.id)
}
async function getPlatformAccount(platformId) {
@@ -664,8 +728,14 @@ async function getPlatformAccount(platformId) {
if (!platform) {
return { success: false, error: '暂不支持该平台' }
}
if (platform.autoFillSupported !== true) {
return {
success: false,
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。',
}
}
const account = await detectUser(platformId)
const account = await checkPlatformLogin(platform)
if (!account?.loggedIn) {
return {
success: false,
@@ -705,16 +775,36 @@ 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: '缺少待填充内容' }
}
const platform = PLATFORMS.find(item => item?.id === platformId)
if (!platform || !platform.publishUrl) {
return { success: false, error: '暂不支持该平台' }
}
if (platform.autoFillSupported !== true) {
return {
success: false,
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
}
}
const account = await getPlatformAccount(platformId)
if (!account.success) return account
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) {
@@ -740,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) {
@@ -764,6 +855,17 @@ async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
return { success: false, error: '缺少有效的平台编辑器标签页' }
}
const platform = PLATFORMS.find(item => item?.id === platformId)
if (!platform || !platform.publishUrl) {
return { success: false, error: '暂不支持该平台' }
}
if (platform.autoFillSupported !== true) {
return {
success: false,
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
}
}
const account = await getPlatformAccount(platformId)
if (!account.success) return account
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
@@ -858,7 +960,7 @@ async function syncToPlatform(platformId, content, options = {}) {
if (!platform || !platform.publishUrl) {
return { success: false, message: '暂不支持该平台' }
}
if (platform.autoFillSupported === false) {
if (platform.autoFillSupported !== true) {
return {
success: false,
message: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
@@ -868,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) {
@@ -1656,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)
}
@@ -3498,119 +3600,6 @@ function fillContentOnPage(content, platformId) {
console.log('[COSE] 头条未找到编辑器')
}
}
// 思否 SegmentFault
else if (host.includes('segmentfault.com')) {
// 填充标题
const titleInput = await waitFor('input#title, input[placeholder*="标题"]')
if (titleInput) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 思否标题填充成功')
} else {
console.log('[COSE] 思否未找到标题输入框')
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// 思否使用 CodeMirror 编辑器
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] 思否 CodeMirror 填充成功')
} else {
// 降级到 textarea
const textarea = document.querySelector('textarea')
if (textarea) {
textarea.focus()
textarea.value = contentToFill
textarea.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 思否 textarea 填充成功')
} else {
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,
}
+365 -4
View File
@@ -1,4 +1,5 @@
// 阿里云开发者社区平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const AliyunPlatform = {
id: 'aliyun',
name: 'Aliyun',
@@ -7,8 +8,368 @@ const AliyunPlatform = {
publishUrl: 'https://developer.aliyun.com/article/new',
title: '阿里云开发者社区',
type: 'aliyun',
autoFillSupported: false,
unavailableMessage: '阿里云开发者社区尚未完成账号 UID、编辑器写入和草稿状态的受控验证,暂不执行自动填充。请使用复制发布稿手动发布。',
}
export { AliyunPlatform }
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
function parseAliyunPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl, 'https://developer.aliyun.com')
if (url.protocol !== 'https:' || url.hostname !== 'developer.aliyun.com') return null
const published = url.pathname.match(/^\/article\/(\d+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认阿里云开发者社区文章公开地址',
}
} catch {
return null
}
}
function getAliyunDraftId(url) {
return normalizeString(
url.searchParams.get('articleId')
|| url.searchParams.get('article_id')
|| url.searchParams.get('draftId')
|| url.searchParams.get('draft_id')
|| url.searchParams.get('id'),
)
}
function inspectAliyunTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseAliyunPublishedUrl(publicUrl) || parseAliyunPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取阿里云开发者社区标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'developer.aliyun.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开阿里云开发者社区,无法确认发布结果',
}
}
if (/^\/article\/(?:new|edit)\/?$/.test(url.pathname)) {
const draftId = getAliyunDraftId(url)
if (draftId || draftSaved) {
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 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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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 selectContents(element) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
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()
selectContents(editor)
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 hasExpectedTitle(input) {
const expectedTitle = normalizeText(title)
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
}
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)
})
if (!switchButton) return
switchButton.click()
await sleep(800)
}
async function fill() {
const titleInput = await waitForElement([
'#title',
'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(200)
if (!hasExpectedTitle(titleInput)) {
return { success: false, error: '阿里云开发者社区未确认接收标题' }
}
}
if (!markdownContent) return { success: true, method: 'title-only' }
await switchToMarkdownEditor()
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
const editor = codeMirror?.CodeMirror
if (editor && typeof editor.setValue === 'function') {
editor.focus()
editor.setValue(markdownContent)
await sleep(400)
if (hasExpectedMarkdown(editor.getValue?.())) return { success: true, method: 'codemirror' }
}
const textarea = await waitForElement([
'.editor textarea.textarea',
'textarea#article-editor',
'textarea.textarea',
'.markdown-editor textarea',
'.editor-container textarea',
'textarea[placeholder*="正文"]',
'textarea',
], 4000)
if (textarea) {
setInputValue(textarea, markdownContent)
await sleep(300)
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: '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: '阿里云开发者社区未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectAliyunTask(tab, helpers) {
const currentState = inspectAliyunTaskState(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 === 'developer.aliyun.com'
&& /^\/article\/\d+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|文章已保存|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
return inspectAliyunTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncAliyunContent(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: fillAliyunContent,
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 {
AliyunPlatform,
fillAliyunContent,
inspectAliyunTask,
inspectAliyunTaskState,
parseAliyunPublishedUrl,
syncAliyunContent,
}
+43 -4
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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' }
+261 -69
View File
@@ -1,4 +1,3 @@
// CSDN 平台配置
const CSDNPlatform = {
id: 'csdn',
name: 'CSDN',
@@ -7,93 +6,286 @@ const CSDNPlatform = {
publishUrl: 'https://editor.csdn.net/md/',
title: 'CSDN',
type: 'csdn',
autoFillSupported: false,
unavailableMessage: 'CSDN 尚未完成账号 UID、编辑器写入和草稿状态的受控验证,暂不执行自动填充。请使用复制发布稿手动发布。',
}
import { injectUtils } from './common.js'
function parseCsdnPublishedUrl(rawUrl) {
if (!rawUrl) return null
// CSDN 内容填充函数(在页面主世界中执行)
// 此函数会被序列化后注入到页面中执行
// 注意:需要先调用 injectUtils 注入 window.waitFor
function fillCSDNContent(title, markdown, body) {
try {
const url = new URL(rawUrl)
if (url.protocol !== 'https:' || url.hostname !== 'blog.csdn.net') return null
const published = url.pathname.match(/^\/([^/]+)\/article\/details\/(\d+)\/?$/)
if (!published?.[2]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[2],
message: '已确认 CSDN 文章公开地址',
}
} catch {
return null
}
}
function inspectCsdnTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseCsdnPublishedUrl(publicUrl) || parseCsdnPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取 CSDN 编辑器标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'editor.csdn.net') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开 CSDN,无法确认发布结果',
}
}
const articleId = url.searchParams.get('articleId') || url.searchParams.get('article_id')
if (articleId && draftSaved) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: articleId,
message: '已确认 CSDN 编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: articleId
? '内容已填充到 CSDN 编辑器,请等待保存完成或手动保存草稿'
: '内容仍在 CSDN 新建编辑器中,请检查后保存草稿或发布',
}
}
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) {
for (const element of document.querySelectorAll(selector)) {
if (element !== excludedElement && !element.disabled && element.offsetParent !== null) 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 selectContents(element) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
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 setContentEditableValue(editor, value) {
editor.focus()
selectContents(editor)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(editor.textContent) !== normalizeText(value)) editor.textContent = value
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedContent(editor) {
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() {
// 填充标题(使用注入的 window.waitFor
const titleInput = await window.waitFor('.article-bar__title input, input[placeholder*="标题"]')
const titleInput = title ? await getTitleInput() : null
if (title && !titleInput) return { success: false, error: '未找到 CSDN 标题输入框' }
if (titleInput && title) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
setInputValue(titleInput, title)
await sleep(200)
if (!hasExpectedTitle(titleInput)) return { success: false, error: 'CSDN 未确认接收标题' }
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
if (!contentToFill) return { success: true, method: 'title-only' }
// CSDN 使用 contenteditable 的 PRE 元素
const editor = document.querySelector(
'.editor__inner[contenteditable="true"], [contenteditable="true"].markdown-highlighting'
)
const editor = await waitForElement([
'.CodeMirror',
'.cm-editor .cm-content[contenteditable="true"]',
'.editor__inner[contenteditable="true"]',
'.markdown-highlighting[contenteditable="true"]',
'.editor-container textarea',
'.editor-wrapper textarea',
'textarea',
], titleInput)
if (!editor) return { success: false, error: '未找到 CSDN Markdown 编辑器' }
if (editor) {
editor.focus()
// 清空现有内容
editor.textContent = ''
// 直接设置文本内容
editor.textContent = contentToFill
// 触发 input 事件让编辑器识别变化
editor.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] CSDN contenteditable 填充成功')
return { success: true, method: 'contenteditable' }
} else {
// 降级尝试其他方式
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] CSDN CodeMirror 填充成功')
return { success: true, method: 'CodeMirror' }
} else {
console.log('[COSE] CSDN 未找到编辑器元素')
return { success: false, error: 'Editor not found' }
if (editor.CodeMirror && typeof editor.CodeMirror.setValue === 'function') {
editor.CodeMirror.setValue(contentToFill)
editor.CodeMirror.focus?.()
await sleep(500)
if (normalizeContent(editor.CodeMirror.getValue?.()) === normalizeContent(contentToFill)) {
return { success: true, method: 'codemirror' }
}
} else if (editor instanceof HTMLTextAreaElement) {
setInputValue(editor, contentToFill)
await sleep(300)
if (hasExpectedContent(editor)) return { success: true, method: 'textarea' }
} else if (editor.isContentEditable) {
setContentEditableValue(editor, contentToFill)
await sleep(300)
if (hasExpectedContent(editor)) return { success: true, method: 'contenteditable' }
}
return {
success: false,
error: 'CSDN 未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
/**
* CSDN 同步处理器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncCSDNContent(tab, content, helpers) {
const { chrome } = helpers
async function inspectCsdnTask(tab, helpers) {
const currentState = inspectCsdnTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
// 等待页面加载
await new Promise(resolve => setTimeout(resolve, 2000))
// 先注入公共工具函数(waitFor, setInputValue
await injectUtils(chrome, tab.id)
// 在页面中执行填充脚本
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillCSDNContent,
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已同步到 CSDN', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'blog.csdn.net'
&& /^\/[^/]+\/article\/details\/\d+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|文章已保存|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
return inspectCsdnTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
// 导出
export { CSDNPlatform, fillCSDNContent, syncCSDNContent }
async function syncCsdnContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取 CSDN 编辑器标签页' }
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillCsdnContent,
args: [content.title, content.markdown || content.body || '', content.body || ''],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || 'CSDN 内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已填充 CSDN 编辑器,请检查后手动保存草稿或发布',
tabId: tab.id,
}
}
export {
CSDNPlatform,
fillCsdnContent,
inspectCsdnTask,
inspectCsdnTaskState,
parseCsdnPublishedUrl,
syncCsdnContent,
}
+313 -52
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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,
}
+79 -44
View File
@@ -1,9 +1,9 @@
// 平台配置汇总
// 从 @cose/detection 导入登录检测配置
import { LOGIN_CHECK_CONFIG } from '../../detection/index.js'
import { LOGIN_CHECK_CONFIG } from '../../detection/src/configs.js'
// 平台元数据和同步函数从各平台文件导入
import { CSDNPlatform, syncCSDNContent } from './csdn.js'
import { CSDNPlatform, inspectCsdnTask, syncCsdnContent } from './csdn.js'
import { inspectJuejinTaskState, JuejinPlatform, syncJuejinContent } from './juejin.js'
import { WechatPlatform, syncWechatContent } from './wechat.js'
import {
@@ -16,10 +16,27 @@ import {
ToutiaoPlatform,
syncToutiaoContent,
} from './toutiao.js'
import { SegmentFaultPlatform } from './segmentfault.js'
import {
inspectSegmentFaultTask,
SegmentFaultPlatform,
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,
@@ -50,7 +67,11 @@ import { SspaiPlatform } from './sspai.js'
import { inspectSohuTask, SohuPlatform, syncSohuContent } from './sohu.js'
import { BilibiliPlatform } from './bilibili.js'
import { WeiboPlatform } from './weibo.js'
import { AliyunPlatform } from './aliyun.js'
import {
AliyunPlatform,
inspectAliyunTask,
syncAliyunContent,
} from './aliyun.js'
import { HuaweiCloudPlatform } from './huaweicloud.js'
import { HuaweiDevPlatform } from './huaweidev.js'
import { TwitterPlatform } from './twitter.js'
@@ -63,8 +84,29 @@ import { XiaohongshuPlatform } from './xiaohongshu.js'
import { ElecfansPlatform } from './elecfans.js'
import { DoubanPlatform } from './douban.js'
const AUTO_FILL_PLATFORM_IDS = new Set([
'csdn',
'wechat',
'juejin',
'jianshu',
'zhihu',
'toutiao',
'baijiahao',
'wangyi',
'sohu',
'infoq',
'tencentcloud',
'aliyun',
'segmentfault',
'51cto',
'oschina',
'qiehao',
])
const AUTO_FILL_UNAVAILABLE_MESSAGE = '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。'
// 合并平台配置
const PLATFORMS = [
const PLATFORM_DEFINITIONS = [
CSDNPlatform,
JuejinPlatform,
WechatPlatform,
@@ -73,6 +115,7 @@ const PLATFORMS = [
SegmentFaultPlatform,
CnblogsPlatform,
OSChinaPlatform,
QiehaoPlatform,
CTO51Platform,
InfoQPlatform,
JianshuPlatform,
@@ -98,47 +141,22 @@ const PLATFORMS = [
DoubanPlatform,
]
// 根据 hostname 获取平台填充函数
function getPlatformFiller(hostname) {
if (hostname.includes('csdn.net')) return 'csdn'
if (hostname.includes('juejin.cn')) return 'juejin'
if (hostname.includes('mp.weixin.qq.com')) return 'wechat'
if (hostname.includes('zhihu.com')) return 'zhihu'
if (hostname.includes('toutiao.com')) return 'toutiao'
if (hostname.includes('segmentfault.com')) return 'segmentfault'
if (hostname.includes('cnblogs.com')) return 'cnblogs'
if (hostname.includes('oschina.net')) return 'oschina'
if (hostname.includes('51cto.com')) return 'cto51'
if (hostname.includes('infoq.cn')) return 'infoq'
if (hostname.includes('jianshu.com')) return 'jianshu'
if (hostname.includes('baijiahao.baidu.com')) return 'baijiahao'
if (hostname.includes('mp.163.com')) return 'wangyi'
if (hostname.includes('cloud.tencent.com')) return 'tencentcloud'
if (hostname.includes('medium.com')) return 'medium'
if (hostname.includes('sspai.com')) return 'sspai'
if (hostname.includes('mp.sohu.com')) return 'sohu'
if (hostname.includes('member.bilibili.com')) return 'bilibili'
if (hostname.includes('card.weibo.com')) return 'weibo'
if (hostname.includes('developer.aliyun.com')) return 'aliyun'
if (hostname.includes('bbs.huaweicloud.com')) return 'huaweicloud'
if (hostname.includes('developer.huawei.com')) return 'huaweidev'
if (hostname.includes('x.com') || hostname.includes('twitter.com')) return 'twitter'
if (hostname.includes('qianfan.cloud.baidu.com')) return 'qianfan'
if (hostname.includes('open.alipay.com')) return 'alipayopen'
if (hostname.includes('modelscope.cn')) return 'modelscope'
if (hostname.includes('developer.volcengine.com')) return 'volcengine'
if (hostname.includes('creator.douyin.com')) return 'douyin'
if (hostname.includes('creator.xiaohongshu.com')) return 'xiaohongshu'
if (hostname.includes('elecfans.com')) return 'elecfans'
if (hostname.includes('douban.com')) return 'douban'
return 'generic'
}
const PLATFORMS = PLATFORM_DEFINITIONS.map(platform => {
const autoFillSupported = AUTO_FILL_PLATFORM_IDS.has(platform.id)
return {
...platform,
autoFillSupported,
unavailableMessage: autoFillSupported
? platform.unavailableMessage
: platform.unavailableMessage || AUTO_FILL_UNAVAILABLE_MESSAGE,
}
})
// 同步处理器映射
// 如果平台有自定义同步逻辑,在此注册处理器
// 未注册的平台将使用 background.js 中的通用填充逻辑
const SYNC_HANDLERS = {
csdn: syncCSDNContent,
csdn: syncCsdnContent,
juejin: syncJuejinContent,
jianshu: syncJianshuContent,
wechat: syncWechatContent,
@@ -149,9 +167,15 @@ const SYNC_HANDLERS = {
sohu: syncSohuContent,
infoq: syncInfoQContent,
tencentcloud: syncTencentCloudContent,
aliyun: syncAliyunContent,
segmentfault: syncSegmentFaultContent,
'51cto': syncCto51Content,
oschina: syncOSChinaContent,
qiehao: syncQiehaoContent,
}
const INSPECT_HANDLERS = {
csdn: inspectCsdnTask,
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
jianshu: inspectJianshuTask,
zhihu: inspectZhihuTask,
@@ -161,7 +185,18 @@ const INSPECT_HANDLERS = {
sohu: inspectSohuTask,
infoq: inspectInfoQTask,
tencentcloud: inspectTencentCloudTask,
aliyun: inspectAliyunTask,
segmentfault: inspectSegmentFaultTask,
'51cto': inspectCto51Task,
oschina: inspectOSChinaTask,
qiehao: inspectQiehaoTask,
}
// 导出
export { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
export {
AUTO_FILL_PLATFORM_IDS,
INSPECT_HANDLERS,
PLATFORMS,
LOGIN_CHECK_CONFIG,
SYNC_HANDLERS,
}
+60 -12
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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' }
+82 -30
View File
@@ -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 },
+99 -39
View File
@@ -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,
+298 -66
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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,
}
+284
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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, '&lt;'), 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,
}
+268 -33
View File
@@ -1,4 +1,3 @@
// 思否平台配置
const SegmentFaultPlatform = {
id: 'segmentfault',
name: 'SegmentFault',
@@ -9,42 +8,278 @@ const SegmentFaultPlatform = {
type: 'segmentfault',
}
// 思否内容填充函数
async function fillSegmentFaultContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
function parseSegmentFaultPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 填充标题
const titleInput = await waitFor('input#title, input[placeholder*="标题"]')
if (titleInput) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 思否标题填充成功')
}
try {
const url = new URL(rawUrl)
if (url.protocol !== 'https:' || url.hostname !== 'segmentfault.com') return null
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
const published = url.pathname.match(/^\/a\/([^/?#]+)\/?$/)
if (!published?.[1]) return null
// 思否使用 CodeMirror 编辑器
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] 思否 CodeMirror 填充成功')
} else {
// 降级到 textarea
const textarea = document.querySelector('textarea')
if (textarea) {
textarea.focus()
textarea.value = contentToFill
textarea.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 思否 textarea 填充成功')
} else {
console.log('[COSE] 思否 未找到编辑器')
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认思否文章公开地址',
}
} catch {
return null
}
}
// 导出
export { SegmentFaultPlatform, fillSegmentFaultContent }
function inspectSegmentFaultTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseSegmentFaultPublishedUrl(publicUrl) || parseSegmentFaultPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取思否编辑器标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'segmentfault.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开思否,无法确认发布结果',
}
}
if (url.pathname !== '/write') {
return {
status: 'publication_uncertain',
url: url.href,
message: '当前思否页面无法确认是草稿还是已发布文章',
}
}
const draftId = url.searchParams.get('draft')
|| url.searchParams.get('draftId')
|| url.searchParams.get('draft_id')
|| url.searchParams.get('articleId')
|| url.searchParams.get('article_id')
|| url.searchParams.get('id')
if (draftId && draftSaved) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认思否编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: draftId
? '内容已填充到思否草稿编辑器,请检查后保存草稿或发布'
: '内容已填充到思否新建编辑器,请检查后保存草稿或发布',
}
}
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) {
for (const selector of selectors) {
for (const element of document.querySelectorAll(selector)) {
if (element !== excludedElement && !element.disabled && element.offsetParent !== null) 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) {
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 setContentEditableValue(editor, value) {
editor.focus()
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(editor.textContent) !== normalizeText(value)) editor.textContent = value
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.dispatchEvent(new Event('blur', { bubbles: true }))
}
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() {
const titleInput = await waitForElement([
'input#title',
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
])
if (title && !titleInput) return { success: false, error: '未找到思否标题输入框' }
if (titleInput && title) {
setInputValue(titleInput, title)
await sleep(200)
if (!hasExpectedTitle(titleInput)) return { success: false, error: '思否未确认接收标题' }
}
if (!contentToFill) return { success: true, method: 'title-only' }
const editor = await waitForElement([
'.CodeMirror',
'.cm-editor .cm-content[contenteditable="true"]',
'.editor-container textarea',
'.editor-wrapper textarea',
'[data-testid*="editor"] [contenteditable="true"]',
'[contenteditable="true"]',
'textarea',
], titleInput)
if (!editor) return { success: false, error: '未找到思否 Markdown 编辑器' }
if (editor.CodeMirror && typeof editor.CodeMirror.setValue === 'function') {
editor.CodeMirror.setValue(contentToFill)
editor.CodeMirror.focus?.()
await sleep(500)
if (hasExpectedContent(editor.CodeMirror.getValue?.())) {
return { success: true, method: 'codemirror' }
}
} else if (editor instanceof HTMLTextAreaElement) {
setInputValue(editor, contentToFill)
await sleep(300)
if (hasExpectedContent(editor.value)) return { success: true, method: 'textarea' }
} else if (editor.isContentEditable) {
setContentEditableValue(editor, contentToFill)
await sleep(300)
if (hasExpectedContent(editor.textContent)) return { success: true, method: 'contenteditable' }
}
return {
success: false,
error: '思否未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectSegmentFaultTask(tab, helpers) {
const currentState = inspectSegmentFaultTaskState(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 === 'segmentfault.com'
&& /^\/a\/[^/?#]+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|文章已保存|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
return inspectSegmentFaultTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncSegmentFaultContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取思否编辑器标签页' }
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillSegmentFaultContent,
args: [content.title, content.markdown || content.body || '', content.body || ''],
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 {
SegmentFaultPlatform,
fillSegmentFaultContent,
inspectSegmentFaultTask,
inspectSegmentFaultTaskState,
parseSegmentFaultPublishedUrl,
syncSegmentFaultContent,
}
+48 -8
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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 }
}
}
+129 -11
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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
+76 -24
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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
+47 -8
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
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: '网易号未确认接收正文,请在编辑器中手动粘贴后再重试',
+52 -17
View File
@@ -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: '知乎未确认接收正文,请在编辑器中手动粘贴后再重试',
+25
View File
@@ -0,0 +1,25 @@
/**
* Convert an avatar URL to a base64 data URL to bypass CORS/ORB blocking.
* The extension fetches only the supplied avatar URL and never reads cookies.
*/
export async function convertAvatarToBase64(avatarUrl, referer) {
try {
const imageResponse = await fetch(avatarUrl, {
headers: { Referer: referer },
})
if (!imageResponse.ok) return avatarUrl
const blob = await imageResponse.blob()
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return `data:${blob.type || 'image/jpeg'};base64,${btoa(binary)}`
} catch (error) {
console.log('[COSE] avatar base64 conversion failed:', error.message)
return avatarUrl
}
}
+2 -2
View File
@@ -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,13 +1,80 @@
/**
* CSDN 当前公开页面没有提供可稳定验证的当前创作者 UID
* 旧实现读取 UserName / UserNick Cookie并把其内容当作账号绑定依据
* 这不符合 AiToEarn 的浏览器发布账号边界因此在完成平台接口验证前不连接账号
*/
async function detectCSDNUser() {
return {
loggedIn: false,
error: 'CSDN 尚未完成稳定账号 UID 验证,请使用复制发布稿手动发布。',
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
function getCsdnBlogSlug(value) {
const blogUrl = normalizeString(value)
if (!blogUrl) return undefined
try {
const url = new URL(blogUrl)
if (url.protocol !== 'https:' || url.hostname !== 'blog.csdn.net') return undefined
return normalizeString(url.pathname.split('/').filter(Boolean)[0])
} catch {
return undefined
}
}
export { detectCSDNUser }
/**
* CSDN 编辑器接口返回的 userName / userId 是可用于账号绑定的稳定标识
* 昵称和头像只用于展示blog_url 的第一段仅在接口未返回数字或账号 ID 时回退使用
*/
function parseCsdnAccount(payload) {
const data = payload?.data?.data || payload?.data || payload?.result?.data || payload || {}
const platformUid = normalizeString(data.userId)
|| normalizeString(data.user_id)
|| normalizeString(data.uid)
|| normalizeString(data.id)
|| normalizeString(data.userName)
|| normalizeString(data.username)
|| normalizeString(data.user_name)
|| getCsdnBlogSlug(data.blogUrl || data.blog_url || data.blogHome)
return {
platformUid,
username: normalizeString(data.nickname)
|| normalizeString(data.nickName)
|| normalizeString(data.name)
|| normalizeString(data.userName)
|| '',
avatar: normalizeString(data.avatar)
|| normalizeString(data.avatarUrl)
|| normalizeString(data.avatar_url)
|| '',
}
}
/**
* 仅调用用户当前 Chrome CSDN 编辑器页面自带的签名请求客户端
* 不读取复制或上传 CookieToken CSRF
*/
async function detectCsdnUser() {
try {
const getProfile = globalThis.__coseCsdnProfile
if (typeof getProfile !== 'function') {
return { loggedIn: false, error: 'CSDN 账号检测通道不可用' }
}
const response = await getProfile()
if (!response || response.status < 200 || response.status >= 300) {
return { loggedIn: false }
}
const account = parseCsdnAccount(response.body)
if (!account.platformUid) return { loggedIn: false }
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : 'CSDN 账号检测失败',
}
}
}
export { detectCsdnUser, parseCsdnAccount }
@@ -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(/&nbsp;/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,4 +1,4 @@
import { convertAvatarToBase64 } from '../utils.js'
import { convertAvatarToBase64 } from '../avatar.js'
export function parseJianshuAccount(json) {
const data = json?.data
@@ -23,16 +23,19 @@ export function parseJianshuAccount(json) {
export async function detectJianshuUser() {
try {
console.log('[COSE] Jianshu Detection: Starting')
const response = await fetch('https://www.jianshu.com/settings/basic.json', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '简书账号检测通道不可用' }
}
if (!response.ok) return { loggedIn: false }
const response = await fetchFromSiteContext(
'https://www.jianshu.com/',
'https://www.jianshu.com/settings/basic.json',
{ responseType: 'json' },
)
if (response?.status < 200 || response?.status >= 300) return { loggedIn: false }
const json = await response.json()
const account = parseJianshuAccount(json)
const account = parseJianshuAccount(response.body)
if (!account) return { loggedIn: false }
let avatar = account.avatar
@@ -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 的企鹅号页面上下文读取已登录编辑器
* 浏览器会自行携带原站登录态扩展不读取复制或上传 CookieTokenCSRF
*/
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 }
@@ -1,47 +1,76 @@
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 parseSegmentFaultAccount(html) {
if (typeof html !== 'string') {
return { platformUid: undefined, username: '', avatar: '' }
}
const matched = html.match(/<script\s+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i)
if (!matched?.[1]) {
return { platformUid: undefined, username: '', avatar: '' }
}
/**
* SegmentFault platform detection logic
* Strategy: Fetch homepage HTML and extract user info from __NEXT_DATA__ JSON
* The /gateway/user/me API requires CSRF tokens (ivd param), so we use HTML scraping instead.
*/
export async function detectSegmentFaultUser() {
try {
console.log('[COSE] SegmentFault Detection: Starting')
const response = await fetch('https://segmentfault.com/', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'text/html' },
})
const html = await response.text()
const nextData = JSON.parse(matched[1])
const globalState = nextData?.props?.pageProps?.initialState?.global || {}
const user = globalState.sessionUser?.user || {}
const sessionInfo = globalState.sessionInfo || {}
// Extract __NEXT_DATA__ JSON
const nextDataMatch = html.match(/<script\s+id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)
if (!nextDataMatch) {
console.log('[COSE] SegmentFault: No __NEXT_DATA__ found')
return { loggedIn: false }
return {
platformUid: normalizeString(user.id)
|| normalizeString(sessionInfo.id)
|| normalizeString(sessionInfo.userId)
|| normalizeString(sessionInfo.user_id),
username: normalizeString(user.name)
|| normalizeString(user.nickname)
|| normalizeString(user.slug)
|| '',
avatar: normalizeString(user.avatar_url)
|| normalizeString(user.avatarUrl)
|| normalizeString(user.avatar)
|| '',
}
const nextData = JSON.parse(nextDataMatch[1])
const sessionUser = nextData?.props?.pageProps?.initialState?.global?.sessionUser
const sessionInfo = nextData?.props?.pageProps?.initialState?.global?.sessionInfo
if (!sessionUser?.user?.id && !sessionInfo?.login) {
console.log('[COSE] SegmentFault: Not logged in')
return { loggedIn: false }
}
const user = sessionUser?.user || {}
const username = user.name || user.slug || ''
let avatar = user.avatar_url || ''
if (avatar && avatar.includes('segmentfault.com')) {
avatar = await convertAvatarToBase64(avatar, 'https://segmentfault.com/')
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] SegmentFault Detection Error:', e)
return { loggedIn: false, error: e.message }
} catch {
return { platformUid: undefined, username: '', avatar: '' }
}
}
/**
* 思否首页内嵌当前会话的 Next.js 初始数据
* 请求在用户当前 Chrome 的同源页面上下文中完成不读取或传输 Cookie
*/
async function detectSegmentFaultUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '思否账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://segmentfault.com/',
'https://segmentfault.com/',
{ responseType: 'text', method: 'GET' },
)
if (!response || response.status < 200 || response.status >= 300) {
return { loggedIn: false }
}
const account = parseSegmentFaultAccount(response.body)
if (!account.platformUid) return { loggedIn: false }
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '思否账号检测失败',
}
}
}
export { detectSegmentFaultUser, parseSegmentFaultAccount }
@@ -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')
@@ -16,10 +16,11 @@ export async function detectWechatUser() {
const cacheAge = Date.now() - (cachedUser.cachedAt || 0)
const maxAge = 1 * 60 * 60 * 1000 // 1 hour
if (cacheAge < maxAge) {
if (cacheAge < maxAge && cachedUser.platformUid) {
console.log(`[COSE] wechat 从缓存读取:`, cachedUser.username)
return {
loggedIn: true,
platformUid: cachedUser.platformUid,
username: cachedUser.username || '',
avatar: cachedUser.avatar || '',
}
@@ -39,9 +40,9 @@ export async function detectWechatUser() {
if (wxData && wxData.nick_name) {
return {
loggedIn: true,
platformUid: wxData.user_name || wxData.fakeid || '',
username: wxData.nick_name || wxData.user_name || '',
avatar: wxData.head_img || '',
token: wxData.t || '',
}
}
return null
@@ -54,6 +55,7 @@ export async function detectWechatUser() {
await chrome.storage.local.set({ wechat_user: userInfo })
return {
loggedIn: true,
platformUid: userInfo.platformUid || '',
username: userInfo.username || '',
avatar: userInfo.avatar || '',
}
@@ -78,19 +80,22 @@ export async function detectWechatUser() {
const nickMatch = html.match(/nick_name\s*[:=]\s*["']([^"']+)["']/)
const avatarMatch = html.match(/head_img\s*[:=]\s*["']([^"']+)["']/)
const platformUidMatch = html.match(/(?:user_name|fakeid)\s*[:=]\s*["']([^"']+)["']/)
if (nickMatch) {
if (nickMatch && platformUidMatch) {
const username = nickMatch[1]
const avatar = avatarMatch ? avatarMatch[1] : ''
const platformUid = platformUidMatch[1]
await chrome.storage.local.set({
wechat_user: {
loggedIn: true,
platformUid,
username,
avatar,
cachedAt: Date.now(),
},
})
return { loggedIn: true, username, avatar }
return { loggedIn: true, platformUid, username, avatar }
}
} catch (e) {
console.log(`[COSE] wechat fetch 失败:`, e.message)
@@ -0,0 +1,120 @@
import {
BaijiahaoLoginConfig,
JuejinLoginConfig,
ToutiaoLoginConfig,
ZhihuLoginConfig,
} 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'
import { detectWangyihaoUser } from './platforms/wangyihao.js'
import { detectWechatUser } from './platforms/wechat.js'
const SITE_CONTEXT_CONFIGS = {
juejin: {
siteUrl: 'https://juejin.cn/',
config: JuejinLoginConfig,
},
zhihu: {
siteUrl: 'https://www.zhihu.com/',
config: ZhihuLoginConfig,
},
toutiao: {
siteUrl: 'https://mp.toutiao.com/',
config: ToutiaoLoginConfig,
},
baijiahao: {
siteUrl: 'https://baijiahao.baidu.com/',
config: BaijiahaoLoginConfig,
},
}
const SUPPORTED_PLATFORM_DETECTORS = {
csdn: detectCsdnUser,
wechat: detectWechatUser,
jianshu: detectJianshuUser,
segmentfault: detectSegmentFaultUser,
wangyi: detectWangyihaoUser,
sohu: detectSohuUser,
infoq: detectInfoQUser,
tencentcloud: detectTencentCloudUser,
aliyun: detectAliyunUser,
'51cto': detectCto51User,
oschina: detectOSChinaUser,
qiehao: detectQiehaoUser,
}
function normalizePlatformUid(value) {
if (typeof value !== 'string' && typeof value !== 'number') return undefined
const normalized = String(value).trim()
return normalized || undefined
}
function normalizeAccount(account) {
if (!account || typeof account !== 'object') {
return { loggedIn: false, error: '平台账号检测未返回有效结果' }
}
return {
...account,
platformUid: normalizePlatformUid(account.platformUid),
}
}
async function detectBySiteContext(platformId, siteConfig) {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: `${platformId} 账号检测通道不可用` }
}
try {
const response = await fetchFromSiteContext(siteConfig.siteUrl, siteConfig.config.api, {
method: siteConfig.config.method || 'GET',
responseType: siteConfig.config.isHtml ? 'text' : 'json',
body: siteConfig.config.body,
})
if (
!response
|| response.status < 200
|| response.status >= 300
|| !siteConfig.config.checkLogin(response.body)
) {
return { loggedIn: false }
}
return normalizeAccount({
loggedIn: true,
...(siteConfig.config.getUserInfo ? siteConfig.config.getUserInfo(response.body) : {}),
})
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : `${platformId} 账号检测失败`,
}
}
}
/**
* Account detection available to the AiToEarn distribution protocol.
* Each detector only uses the signed-in site's current page context.
*/
export async function detectSupportedPlatformUser(platformId) {
const detector = SUPPORTED_PLATFORM_DETECTORS[platformId]
if (detector) return normalizeAccount(await detector())
const siteConfig = SITE_CONTEXT_CONFIGS[platformId]
if (siteConfig) return detectBySiteContext(platformId, siteConfig)
return { loggedIn: false, error: '该平台未开放受控账号检测' }
}
export { SITE_CONTEXT_CONFIGS, SUPPORTED_PLATFORM_DETECTORS }
-49
View File
@@ -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 -2
View File
@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.13.0",
"version": "1.19.6",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
"permissions": [
"activeTab",
"alarms",
"clipboardRead",
"cookies",
"storage",
"contextMenus",
"debugger",
@@ -21,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/*",
@@ -45,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
View File
@@ -46,7 +46,7 @@ async function loadSettings() {
]);
if (response?.error) throw new Error(response.error);
availablePlatforms = Array.isArray(response?.platforms)
? response.platforms.filter(platform => platform?.id && platform?.publishUrl)
? response.platforms.filter(platform => platform?.id && platform?.publishUrl && platform.autoFillSupported === true)
: [];
const availableIds = new Set(availablePlatforms.map(platform => platform.id));
const selectedIds = preferences.defaultPlatforms.filter(platformId => availableIds.has(platformId));
+3 -2
View File
@@ -3,7 +3,8 @@
"private": true,
"type": "module",
"scripts": {
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs",
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1"
"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"
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ async function loadPlatforms() {
chrome.storage.sync.get({ defaultPlatforms: ['wechat'] }),
]);
availablePlatforms = Array.isArray(response?.platforms)
? response.platforms.filter(platform => platform?.id && platform?.publishUrl)
? response.platforms.filter(platform => platform?.id && platform?.publishUrl && platform.autoFillSupported === true)
: [];
const availableIds = new Set(availablePlatforms.map(platform => platform.id));
const selectedIds = preferences.defaultPlatforms.filter(platformId => availableIds.has(platformId));
+66 -1
View File
@@ -60,6 +60,11 @@ if ($LASTEXITCODE -ne 0) {
throw 'Distribution bridge validation failed'
}
& node (Join-Path $PSScriptRoot 'test-browser-agent.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Browser Agent validation failed'
}
$distRoot = Join-Path $repoRoot 'dist'
$stageRoot = Join-Path $distRoot "aitoearn-extension-v$version"
$archivePath = Join-Path $distRoot "aitoearn-extension-v$version.zip"
@@ -89,7 +94,67 @@ foreach ($relativePath in $requiredPaths) {
}
}
Compress-Archive -Path (Join-Path $stageRoot '*') -DestinationPath $archivePath -CompressionLevel Optimal
Add-Type -AssemblyName System.IO.Compression
$archiveStream = $null
$archive = $null
try {
$archiveStream = [System.IO.File]::Open(
$archivePath,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None
)
$archive = [System.IO.Compression.ZipArchive]::new(
$archiveStream,
[System.IO.Compression.ZipArchiveMode]::Create,
$false,
[System.Text.Encoding]::UTF8
)
$fixedTimestamp = [System.DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [System.TimeSpan]::Zero)
$stageRootFullPath = [System.IO.Path]::GetFullPath($stageRoot)
$archiveFiles = Get-ChildItem -LiteralPath $stageRoot -Recurse -File |
ForEach-Object {
[pscustomobject]@{
EntryName = $_.FullName.Substring($stageRootFullPath.Length + 1).Replace('\', '/')
SourcePath = $_.FullName
}
} |
Sort-Object -Property EntryName
foreach ($archiveFile in $archiveFiles) {
$entry = $archive.CreateEntry(
$archiveFile.EntryName,
[System.IO.Compression.CompressionLevel]::Optimal
)
$entry.LastWriteTime = $fixedTimestamp
$entry.ExternalAttributes = 0
$sourceStream = $null
$entryStream = $null
try {
$sourceStream = [System.IO.File]::OpenRead($archiveFile.SourcePath)
$entryStream = $entry.Open()
$sourceStream.CopyTo($entryStream)
}
finally {
if ($null -ne $entryStream) {
$entryStream.Dispose()
}
if ($null -ne $sourceStream) {
$sourceStream.Dispose()
}
}
}
}
finally {
if ($null -ne $archive) {
$archive.Dispose()
}
if ($null -ne $archiveStream) {
$archiveStream.Dispose()
}
}
$verificationRoot = Join-Path $distRoot '.verify'
if (Test-Path -LiteralPath $verificationRoot) {
+360
View File
@@ -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![配图](https://example.com/image.png)',
})
assert.equal(markdownTaskContent.markdown, '# 标题\n\n- 列表项\n\n![配图](https://example.com/image.png)')
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')
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { readFile, stat } from 'node:fs/promises'
const repositoryApiUrl = 'https://git.frp.it1024.cc/api/v1/repos/aitoearn/chrome-extension'
const attachmentBaseUrl = 'https://git.frp.it1024.cc/attachments'
async function main() {
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
throw new Error('Release 校验拒绝在禁用 TLS 证书验证的环境中运行')
}
const manifestUrl = new URL('../manifest.json', import.meta.url)
const manifest = JSON.parse((await readFile(manifestUrl, 'utf8')).replace(/^\uFEFF/, ''))
const version = manifest.version
const tag = `v${version}`
const archiveName = `aitoearn-extension-v${version}.zip`
const archiveUrl = new URL(`../dist/${archiveName}`, import.meta.url)
const releaseResponse = await fetch(`${repositoryApiUrl}/releases/tags/${encodeURIComponent(tag)}`)
if (!releaseResponse.ok) {
throw new Error(`Gitea Release ${tag} 不存在或不可读取 (${releaseResponse.status})`)
}
const release = await releaseResponse.json()
const asset = release.assets?.find(item => item.name === archiveName)
if (!asset?.uuid) {
throw new Error(`Gitea Release ${tag} 缺少 ${archiveName}`)
}
const [archive, archiveStats, webConfig] = await Promise.all([
readFile(archiveUrl),
stat(archiveUrl),
readFile(new URL('../../project/aitoearn-web/src/app/config/browserExtensionConfig.ts', import.meta.url), 'utf8'),
])
if (asset.size !== archiveStats.size) {
throw new Error(`Gitea 资产大小 ${asset.size} 与本地包大小 ${archiveStats.size} 不一致`)
}
if (!webConfig.includes(`browserExtensionVersion = '${version}'`)) {
throw new Error(`前端版本门禁未同步到 ${version}`)
}
const assetUrl = `${attachmentBaseUrl}/${asset.uuid}`
if (!webConfig.includes(`browserExtensionDownloadUrl = '${assetUrl}'`)) {
throw new Error('前端下载地址未同步到当前 Gitea Release 资产')
}
const remoteResponse = await fetch(assetUrl)
if (!remoteResponse.ok) {
throw new Error(`无法下载 Gitea Release 资产 (${remoteResponse.status})`)
}
const remoteArchive = Buffer.from(await remoteResponse.arrayBuffer())
const localHash = createHash('sha256').update(archive).digest('hex')
const remoteHash = createHash('sha256').update(remoteArchive).digest('hex')
if (localHash !== remoteHash) {
throw new Error('Gitea Release 资产哈希与本地包不一致')
}
console.log(JSON.stringify({ version, tag, archiveName, size: archiveStats.size, sha256: localHash, assetUrl }))
}
main().catch(error => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})