Compare commits

...

30 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
admin 026b366f50 feat(extension): 接入腾讯云开发者社区交接 2026-08-16 05:08:13 +08:00
admin 61cdcc1afb fix(extension): block unverified platform auto-fill 2026-08-16 03:32:53 +08:00
admin e2244a20bf feat(extension): 接入InfoQ浏览器发布交接 2026-08-16 02:58:43 +08:00
admin 8612e86ba3 docs(extension): 更新搜狐号交接说明 2026-08-16 02:40:02 +08:00
admin 77d44bb52f feat(extension): 接入搜狐号受控发布交接 2026-08-16 02:34:23 +08:00
admin 7988fdab53 docs(extension): 更新网易号交接说明 2026-08-16 02:23:53 +08:00
admin c98c2d3864 feat(extension): 接入网易号受控发布交接 2026-08-16 02:19:03 +08:00
admin f6f2c03790 feat(extension): 接入百家号浏览器发布交接 2026-08-16 01:58:22 +08:00
admin 6f55d2c809 feat(extension): 接入今日头条浏览器发布交接 2026-08-16 01:45:20 +08:00
admin 1e48681c86 feat(extension): 接入知乎浏览器发布交接 2026-08-16 01:20:03 +08:00
admin fd192bf9ba feat(extension): 接入简书浏览器发布交接 2026-08-16 00:33:20 +08:00
admin bd963dea72 feat(extension): 完善浏览器发布交接与受控互动 2026-08-15 23:52:23 +08:00
admin 3c4fbf6310 feat(interaction): add controlled douyin comment bridge 2026-08-12 09:32:23 +08:00
58 changed files with 10838 additions and 2078 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.zip binary
+1
View File
@@ -1,2 +1,3 @@
dist/
aitoearn-extension.zip
node_modules/
+17 -2
View File
@@ -5,9 +5,22 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
- 从当前网页采集标题、正文和图片。
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动桥接,不冒充旧官方扩展的完整能力。
- 复用浏览器现有登录状态检测内容平台账号。
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
- 扩展弹窗从 COSE 适配器动态读取已完成受控填充验证的平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
## Browser Provider
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入 CSDN、掘金、简书、知乎、今日头条、百家号、网易号、搜狐号、InfoQ、腾讯云开发者社区、阿里云开发者社区、思否、51CTO、开源中国和腾讯内容开放平台(企鹅号):扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。CSDN 只使用编辑器自带的签名请求客户端读取创作者资料,思否只从当前页面的 Next.js 会话数据读取 UID;网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID,51CTO 只接受个人空间页面中可验证的稳定 UID,开源中国只接受官方账号接口返回的稳定 UID,企鹅号只接受页面中的稳定 `userID` / `userId`
扩展支持服务端随发布任务下发经过签名安装、Schema 校验并固化版本身份的声明式 Browser Provider 快照。插件只能覆盖核心十五个平台的标题、正文与第一层草稿/发布按钮定义;平台 URL、账号检测和最终结果判断仍由扩展核心维护。扩展会再次严格校验平台、选择器、编辑器类型、内容格式、源提交和内容哈希,写入后回读标题、正文长度和图片数量。配置漂移或回读失败时保留编辑器并返回明确失败,不执行仓库代码,也不会把未知按钮当作核心动作继续点击。
Browser Provider 仅负责账号核对、编辑器填充和草稿/公开文章状态回读,不会自动点击最终公开发布按钮。所有已接入平台仍保留复制发布稿入口;未进入受控交接白名单的平台继续只支持手动复制,不会伪报“已同步”。
扩展弹窗还保留公众号的单次人工确认填充入口,但不把它伪装成 Browser Provider。其他旧 COSE 选择器不会出现在扩展平台列表中,也不能通过桥接接口绕过能力白名单;仅有选择器或静态平台配置不代表平台已经接入。
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。CSDN 只有 `blog.csdn.net/:author/article/details/:id`、掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id``www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id``www.infoq.cn/article/:id`、腾讯云只有 `cloud.tencent.com/developer/article/:id`、阿里云只有 `developer.aliyun.com/article/:id`、思否只有 `segmentfault.com/a/:id`、51CTO 只有 `blog.51cto.com/u_:uid/:id` 才记为已发布;带草稿标识的编辑器地址,以及网易号、搜狐号、InfoQ、51CTO 或开源中国页面明确提示草稿保存成功时才记为草稿。开源中国的公开文章 URL 尚未在真实创作者账号上验收,因此未知页面会进入“发布结果待确认”,不会伪报成功。
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
@@ -15,6 +28,8 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
当前互动桥接只开放受控白名单:能力查询、抖音账号核对、评论列表、回复列表和单条人工回复。作品 ID、评论 ID、账号 UID、回复内容、游标和数量均由扩展校验,网页不能向扩展传入任意请求地址。发送回复前必须由用户在 AiToEarn 界面逐条确认,扩展会再次核对当前浏览器账号与所选 AiToEarn 账号,写请求不会自动重试,也不支持批量回复、自动回复或私信。扩展优先复用当前 Chrome 中已有的抖音标签页,没有可复用标签页时才创建非激活标签页,并在抖音页面上下文内发起带现有 Cookie 的请求。
## 上游来源
多平台检测与编辑器填充逻辑基于 `doocs/cose`,固定版本和许可证见:
@@ -43,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
+13 -2
View File
@@ -1,4 +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';
@@ -210,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;
}
+51
View File
@@ -5,9 +5,16 @@
'getVersion',
'listPlatforms',
'checkPlatforms',
'getPlatformAccount',
'captureUrl',
'startBatch',
'syncToPlatform',
'openPlatformTask',
'inspectPlatformTask',
'pairBrowserAgent',
'getBrowserAgentStatus',
'pollBrowserAgentNow',
'disconnectBrowserAgent',
]);
async function sendRuntimeMessage(message) {
@@ -34,6 +41,14 @@
const response = await sendRuntimeMessage({ type: 'CHECK_PLATFORM_STATUS', platforms: selected });
return response?.status || {};
}
case 'getPlatformAccount': {
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
throw new Error('缺少目标平台');
return sendRuntimeMessage({
type: 'GET_PLATFORM_ACCOUNT',
platformId: payload.platformId,
});
}
case 'captureUrl': {
if (typeof payload?.url !== 'string' || !payload.url.trim())
throw new Error('缺少要采集的页面地址');
@@ -52,6 +67,42 @@
content: payload.content,
});
}
case 'openPlatformTask': {
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
throw new Error('缺少目标平台');
if (!payload.content || typeof payload.content !== 'object')
throw new Error('缺少分发内容');
return sendRuntimeMessage({
type: 'OPEN_PLATFORM_TASK',
platformId: payload.platformId,
expectedPlatformUid: payload.expectedPlatformUid,
tabId: payload.tabId,
content: payload.content,
});
}
case 'inspectPlatformTask': {
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
throw new Error('缺少目标平台');
if (!Number.isInteger(payload?.tabId) || payload.tabId <= 0)
throw new Error('缺少有效的平台编辑器标签页');
return sendRuntimeMessage({
type: 'INSPECT_PLATFORM_TASK',
platformId: payload.platformId,
tabId: payload.tabId,
expectedPlatformUid: payload.expectedPlatformUid,
});
}
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}`);
}
+16
View File
@@ -44,9 +44,25 @@
getVersion: () => request('getVersion'),
listPlatforms: () => request('listPlatforms'),
checkPlatforms: platformIds => request('checkPlatforms', { platformIds }),
getPlatformAccount: platformId => request('getPlatformAccount', { platformId }),
captureUrl: url => request('captureUrl', { url }),
startBatch: () => request('startBatch'),
syncToPlatform: (platformId, content) => request('syncToPlatform', { platformId, content }),
openPlatformTask: (platformId, content, expectedPlatformUid, tabId) => request('openPlatformTask', {
platformId,
content,
expectedPlatformUid,
tabId,
}),
inspectPlatformTask: (platformId, tabId, expectedPlatformUid) => request('inspectPlatformTask', {
platformId,
tabId,
expectedPlatformUid,
}),
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,
}
+377 -397
View File
@@ -1,7 +1,17 @@
// 平台配置
import { 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 =====
@@ -122,7 +132,12 @@ function sendOffscreenMessage(msg, timeoutMs = 15000) {
* then inject a script that makes the fetch with credentials: 'include'.
*/
async function tabContextFetch(siteUrl, apiUrl, options = {}) {
const { responseType = 'json', timeout = 15000 } = options
const {
responseType = 'json',
timeout = 15000,
method = 'GET',
body,
} = options
let createdTabId = null
try {
const urlObj = new URL(siteUrl)
@@ -166,12 +181,18 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
// so that credentials: 'include' sends the page's cookies
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async (fetchUrl, respType) => {
func: async (fetchUrl, respType, requestMethod, requestBody) => {
try {
const headers = {
Accept: respType === 'json' ? 'application/json' : 'text/html',
}
if (requestBody !== undefined) headers['Content-Type'] = 'application/json'
const resp = await fetch(fetchUrl, {
method: 'GET',
method: requestMethod,
credentials: 'include',
headers: { Accept: respType === 'json' ? 'application/json' : 'text/html' },
headers,
body: requestBody,
})
const status = resp.status
const finalUrl = resp.url
@@ -190,7 +211,7 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
return { error: e.message }
}
},
args: [apiUrl, responseType],
args: [apiUrl, responseType, method, body],
world: 'MAIN',
})
@@ -225,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.
@@ -438,6 +513,9 @@ const COSE_MESSAGE_TYPES = new Set([
'GET_PLATFORMS',
'CHECK_PLATFORM_STATUS',
'CHECK_PLATFORM_STATUS_PROGRESSIVE',
'GET_PLATFORM_ACCOUNT',
'OPEN_PLATFORM_TASK',
'INSPECT_PLATFORM_TASK',
'START_SYNC_BATCH',
'SYNC_TO_PLATFORM',
'CACHE_USER_INFO',
@@ -477,60 +555,78 @@ 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':
return await openPlatformTask(
request.platformId,
request.content,
request.expectedPlatformUid,
request.tabId,
request.providerConfig
)
case 'INSPECT_PLATFORM_TASK':
return await inspectPlatformTask(
request.platformId,
request.tabId,
request.expectedPlatformUid
)
case 'START_SYNC_BATCH':
// 开始新的同步批次,重置 tab group
currentSyncGroupId = null
return { success: true }
case 'SYNC_TO_PLATFORM':
return await syncToPlatform(request.platformId, request.content)
return await syncToPlatform(request.platformId, request.content, request.options)
case 'CACHE_USER_INFO':
// 缓存用户信息
if (request.platform === 'xiaohongshu' && request.userInfo) {
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 {
@@ -554,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
@@ -619,14 +715,183 @@ 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) {
const platform = PLATFORMS.find(item => item?.id === platformId)
if (!platform) {
return { success: false, error: '暂不支持该平台' }
}
if (platform.autoFillSupported !== true) {
return {
success: false,
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。',
}
}
const account = await checkPlatformLogin(platform)
if (!account?.loggedIn) {
return {
success: false,
loggedIn: false,
error: account?.error || `请先在当前 Chrome 中登录${platform.title || platform.name}`,
}
}
if (!account.platformUid) {
return {
success: false,
loggedIn: true,
error: '已检测到登录状态,但平台没有返回账号 UID',
}
}
return {
success: true,
loggedIn: true,
protocolVersion: 1,
platformId,
platformUid: String(account.platformUid),
displayName: account.username || String(account.platformUid),
avatarUrl: account.avatar || undefined,
extensionVersion: chrome.runtime.getManifest().version,
}
}
function validateExpectedPlatformAccount(account, expectedPlatformUid) {
if (expectedPlatformUid && account.platformUid !== expectedPlatformUid) {
return {
success: false,
error: `当前 Chrome 登录账号与任务绑定账号不一致(当前:${account.displayName}`,
code: 'ACCOUNT_MISMATCH',
account,
}
}
return null
}
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId, 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) {
try {
const existingTab = await chrome.tabs.get(existingTabId)
const existingState = await inspect(existingTab, { chrome })
if (canReturnExistingPlatformTaskState(existingState)) {
await chrome.tabs.update(existingTabId, { active: true })
return {
success: true,
...existingState,
protocolVersion: 1,
platformUid: account.platformUid,
displayName: account.displayName,
extensionVersion: account.extensionVersion,
tabId: existingTabId,
}
}
reusableTabId = existingTabId
} catch {}
}
const result = await syncToPlatform(platformId, content, {
active: true,
tabId: reusableTabId,
providerConfig: validatedProviderConfig,
})
if (!result?.success) return result
if (result.tabId) {
await chrome.tabs.update(result.tabId, { active: true })
}
const tab = result.tabId ? await chrome.tabs.get(result.tabId) : null
const state = inspect && tab
? await inspect(tab, { chrome })
: { status: 'filled' }
return {
...result,
...state,
protocolVersion: 1,
platformUid: account.platformUid,
displayName: account.displayName,
extensionVersion: account.extensionVersion,
}
}
async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
if (!Number.isInteger(tabId) || tabId <= 0) {
return { success: false, error: '缺少有效的平台编辑器标签页' }
}
const 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
const inspect = INSPECT_HANDLERS[platformId]
if (!inspect) {
return { success: false, error: '该平台暂不支持发布结果核对' }
}
let tab
try {
tab = await chrome.tabs.get(tabId)
} catch {
return { success: false, error: '平台编辑器标签页已经关闭,请重新打开并填充' }
}
const state = await inspect(tab, { chrome })
return {
success: state.status !== 'failed',
...state,
protocolVersion: 1,
platformUid: account.platformUid,
displayName: account.displayName,
extensionVersion: account.extensionVersion,
tabId,
}
}
async function pasteWithDebugger(tabId) {
const debuggee = { tabId }
@@ -690,23 +955,53 @@ async function pasteWithDebugger(tabId) {
}
// 同步到平台
async function syncToPlatform(platformId, content) {
async function syncToPlatform(platformId, content, options = {}) {
const platform = PLATFORMS.find(p => p && p.id === platformId)
if (!platform || !platform.publishUrl) {
return { success: false, message: '暂不支持该平台' }
}
if (platform.autoFillSupported !== true) {
return {
success: false,
message: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
}
}
try {
let tab
if (options.providerConfig) {
return await syncDeclarativeBrowserProvider(
platform,
content,
options.providerConfig,
options,
{
chrome,
waitForTab,
addTabToSyncGroup,
},
)
}
// 检查是否有平台特定的同步处理器
const syncHandler = SYNC_HANDLERS[platformId]
if (syncHandler) {
console.log(`[COSE] 使用 ${platformId} 平台特定同步处理器`)
// 创建标签页(对于微信等需要特殊处理的平台,使用首页)
// 创建或复用任务标签页(对于微信等需要特殊处理的平台,使用首页)
const initialUrl = platformId === 'wechat' ? 'https://mp.weixin.qq.com/' : platform.publishUrl
tab = await chrome.tabs.create({ url: initialUrl, active: false })
await addTabToSyncGroup(tab.id, tab.windowId)
if (Number.isInteger(options.tabId) && options.tabId > 0) {
try {
tab = await chrome.tabs.update(options.tabId, {
url: initialUrl,
active: options.active === true,
})
} catch {}
}
if (!tab) {
tab = await chrome.tabs.create({ url: initialUrl, active: options.active === true })
await addTabToSyncGroup(tab.id, tab.windowId)
}
// 调用平台特定处理器
const helpers = {
@@ -720,90 +1015,7 @@ async function syncToPlatform(platformId, content) {
// ==== 以下是原有的平台特定逻辑(待迁移)====
if (platformId === 'infoq') {
// InfoQ:需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write
try {
// 调用创建草稿 API
const response = await fetch('https://xie.infoq.cn/api/v1/draft/create', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
})
const data = await response.json()
if (data.code === 0 && data.data?.id) {
const draftId = data.data.id
const targetUrl = `https://xie.infoq.cn/draft/${draftId}`
console.log('[COSE] InfoQ 创建草稿成功,ID:', draftId)
tab = await chrome.tabs.create({ url: targetUrl, active: false })
await addTabToSyncGroup(tab.id, tab.windowId)
await waitForTab(tab.id)
} else {
console.error('[COSE] InfoQ 创建草稿失败:', data)
return { success: false, message: 'InfoQ 创建草稿失败,请确保已登录' }
}
} catch (e) {
console.error('[COSE] InfoQ API 调用失败:', e)
return { success: false, message: 'InfoQ API 调用失败: ' + e.message }
}
} else if (platformId === 'jianshu') {
// 简书:需要先获取文集列表,然后创建新文章
try {
// 获取用户的文集列表
const notebooksResp = await fetch('https://www.jianshu.com/author/notebooks', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
const notebooks = await notebooksResp.json()
if (!notebooks || notebooks.length === 0) {
return { success: false, message: '简书未找到文集,请先创建一个文集' }
}
// 使用第一个文集
const notebookId = notebooks[0].id
console.log('[COSE] 简书使用文集:', notebooks[0].name, 'ID:', notebookId)
// 创建新文章
const createResp = await fetch('https://www.jianshu.com/author/notes', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
notebook_id: String(notebookId),
title: content.title || '无标题',
at_bottom: false,
}),
})
const noteData = await createResp.json()
if (noteData && noteData.id) {
const noteId = noteData.id
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${noteId}`
console.log('[COSE] 简书创建文章成功,ID:', noteId)
tab = await chrome.tabs.create({ url: targetUrl, active: false })
await addTabToSyncGroup(tab.id, tab.windowId)
await waitForTab(tab.id)
} else {
console.error('[COSE] 简书创建文章失败:', noteData)
return { success: false, message: '简书创建文章失败,请确保已登录' }
}
} catch (e) {
console.error('[COSE] 简书 API 调用失败:', e)
return { success: false, message: '简书 API 调用失败: ' + e.message }
}
} else if (platformId === 'xiaohongshu') {
if (platformId === 'xiaohongshu') {
// 小红书:需要先点击"新的创作"按钮,等待编辑器加载后填充
console.log('[COSE] 开始处理小红书同步...')
@@ -1560,22 +1772,8 @@ async function syncToPlatform(platformId, content) {
} 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)
}
@@ -2110,68 +2308,6 @@ async function syncToPlatform(platformId, content) {
return { success: true, message: '已同步到微博头条', tabId: tab.id }
}
// 阿里云开发者社区:使用 Markdown 编辑器
if (platformId === 'aliyun') {
// 等待页面完全加载
await new Promise(resolve => setTimeout(resolve, 3000))
// 阿里云使用 Markdown 编辑器
const markdownContent = content.markdown || content.body || ''
console.log('[COSE] 阿里云开发者社区 Markdown 内容长度:', markdownContent?.length || 0)
// 填充标题和内容
const fillResult = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (title, markdown) => {
// 填充标题
const titleInput = document.querySelector('input[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
// 使用 native setter 来绕过 React 的受控组件
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 阿里云开发者社区标题填充成功')
}
// 填充内容 - 阿里云使用 textarea 作为 Markdown 编辑器
const contentTextarea =
document.querySelector('textarea[class*="editor"]') ||
document.querySelector('.markdown-editor textarea') ||
document.querySelector('textarea:not([placeholder*="标题"])')
if (contentTextarea && markdown) {
contentTextarea.focus()
// 使用 native setter 来绕过 React 的受控组件
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(contentTextarea, markdown)
contentTextarea.dispatchEvent(new Event('input', { bubbles: true }))
contentTextarea.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 阿里云开发者社区内容填充成功')
return { success: true }
}
return { success: false, error: 'Editor not found' }
},
args: [content.title, markdownContent],
world: 'MAIN',
})
console.log('[COSE] 阿里云开发者社区填充结果:', fillResult)
// 等待内容注入完成
await new Promise(resolve => setTimeout(resolve, 1000))
return { success: true, message: '已同步到阿里云开发者社区', tabId: tab.id }
}
// 火山引擎开发者社区:使用 ByteMD 编辑器(基于 CodeMirror
if (platformId === 'volcengine') {
// 等待页面完全加载
@@ -3464,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')) {
// 等待页面加载
@@ -3695,49 +3718,6 @@ function fillContentOnPage(content, platformId) {
document.head.appendChild(script)
script.remove()
}
// 简书
else if (host.includes('jianshu.com')) {
// 填充标题 - 简书使用 input._24i7u,需要使用 native setter
const titleInput = await waitFor('input._24i7u, input[class*="title"]')
if (titleInput) {
titleInput.focus()
const inputSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
inputSetter.call(titleInput, title)
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 简书标题填充成功')
} else {
console.log('[COSE] 简书未找到标题输入框')
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 500))
// 简书使用 textarea#arthur-editor 作为 Markdown 编辑器
const editor =
document.querySelector('#arthur-editor') || document.querySelector('textarea._3swFR')
if (editor) {
editor.focus()
const textareaSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
textareaSetter.call(editor, contentToFill)
editor.dispatchEvent(
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' })
)
editor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 简书内容填充成功')
} else {
console.log('[COSE] 简书未找到编辑器')
}
}
// 腾讯云开发者社区
else if (host.includes('cloud.tencent.com')) {
console.log('[COSE] TencentCloud 开始同步...')
@@ -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,
}
+363 -41
View File
@@ -1,53 +1,375 @@
// 阿里云开发者社区平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const AliyunPlatform = {
id: 'aliyun',
name: 'Aliyun',
icon: 'https://img.alicdn.com/tfs/TB1_ZXuNcfpK1RjSZFOXXa6nFXa-32-32.ico',
url: 'https://developer.aliyun.com/',
publishUrl: 'https://developer.aliyun.com/article/new#/',
publishUrl: 'https://developer.aliyun.com/article/new',
title: '阿里云开发者社区',
type: 'aliyun',
}
// 阿里云开发者社区内容填充函数
async function fillAliyunContent(content) {
const { title, markdown } = content
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
console.log('[COSE] 阿里云开发者社区:开始填充内容')
// 等待页面加载
await new Promise(resolve => setTimeout(resolve, 2000))
// 填充标题
const titleInput = document.querySelector('input[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 阿里云开发者社区:标题已填充')
}
// 等待一下再填充正文
await new Promise(resolve => setTimeout(resolve, 500))
// 填充正文(markdown 编辑器)
// 阿里云开发者社区使用的是 markdown 编辑器,textarea 是主要输入区域
const contentTextarea =
document.querySelector('textarea[class*="editor"]') ||
document.querySelector('.markdown-editor textarea') ||
document.querySelector('textarea')
if (contentTextarea && markdown) {
contentTextarea.focus()
contentTextarea.value = markdown
contentTextarea.dispatchEvent(new Event('input', { bubbles: true }))
contentTextarea.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 阿里云开发者社区:正文已填充')
}
console.log('[COSE] 阿里云开发者社区:内容填充完成')
const normalized = value.trim()
return normalized || undefined
}
// 导出
export { AliyunPlatform, fillAliyunContent }
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,
}
+336 -79
View File
@@ -1,5 +1,6 @@
// 百家号平台配置
const BaijiahaoPlat = {
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const BaijiahaoPlatform = {
id: 'baijiahao',
name: 'Baijiahao',
icon: 'https://pic.rmb.bdstatic.com/10e1e2b43c35577e1315f0f6aad6ba24.vnd.microsoft.icon',
@@ -9,86 +10,342 @@ const BaijiahaoPlat = {
type: 'baijiahao',
}
// 百家号内容填充函数
async function fillBaijiahaoContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
function parseBaijiahaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 1. 填充标题
// 百家号标题输入框在 .client_components_titleInput 内的 contenteditable div
await new Promise(resolve => setTimeout(resolve, 1000))
try {
const url = new URL(rawUrl, 'https://baijiahao.baidu.com')
if (url.protocol !== 'https:' || url.hostname !== 'baijiahao.baidu.com') return null
const platformWorkId = url.pathname === '/s' ? url.searchParams.get('id') : undefined
if (!platformWorkId?.trim()) return null
const titleEditor =
document.querySelector('.client_components_titleInput [contenteditable="true"]') ||
document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]') ||
document.querySelector('[class*="titleInput"] [contenteditable="true"]')
if (titleEditor) {
titleEditor.focus()
// 清空现有内容
titleEditor.innerHTML = ''
// 使用 document.execCommand 插入文本
document.execCommand('insertText', false, title)
// 如果 execCommand 不生效,使用备用方案
if (!titleEditor.textContent) {
titleEditor.innerHTML = `<p dir="auto">${title}</p>`
return {
status: 'published',
url: url.href,
platformWorkId: platformWorkId.trim(),
message: '已确认百家号文章公开地址',
}
titleEditor.dispatchEvent(new Event('input', { bubbles: true }))
titleEditor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 百家号标题填充成功')
} else {
console.log('[COSE] 百家号未找到标题输入框')
}
// 2. 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1500))
// 3. 填充正文内容
// 百家号使用 UEditor,内容在 iframe 中
const iframe = document.querySelector('iframe')
if (iframe && iframe.contentDocument) {
const iframeBody = iframe.contentDocument.body
if (iframeBody && iframeBody.contentEditable === 'true') {
iframeBody.focus()
// 将 markdown 转换为简单的 HTML 段落
const htmlContent = contentToFill
.split('\n\n')
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
iframeBody.innerHTML = htmlContent
iframeBody.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 百家号 iframe 编辑器填充成功')
return
}
}
// 尝试通过 UEditor API 填充
if (window.UE_V2 && window.UE_V2.instants && window.UE_V2.instants.ueditorInstant0) {
try {
const editor = window.UE_V2.instants.ueditorInstant0
const htmlContent = contentToFill
.split('\n\n')
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
editor.setContent(htmlContent)
console.log('[COSE] 百家号通过 UEditor API 填充成功')
return
} catch (e) {
console.log('[COSE] 百家号 UEditor API 调用失败', e)
}
}
// 降级:尝试直接操作 contenteditable
const contentEditor = document.querySelector('[contenteditable="true"]:not([class*="title"])')
if (contentEditor) {
contentEditor.focus()
contentEditor.innerHTML = contentToFill.replace(/\n/g, '<br>')
contentEditor.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 百家号 contenteditable 降级填充成功')
} else {
console.log('[COSE] 百家号未找到编辑器元素')
} catch {
return null
}
}
// 导出
export { BaijiahaoPlat as BaijiahaoPlatform, fillBaijiahaoContent }
function getBaijiahaoDraftId(url) {
for (const key of ['article_id', 'articleId', 'draft_id', 'draftId']) {
const value = url.searchParams.get(key)
if (value?.trim()) return value.trim()
}
return undefined
}
function inspectBaijiahaoTaskState(rawUrl, publicUrl) {
const published = parseBaijiahaoPublishedUrl(publicUrl) || parseBaijiahaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取百家号标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'baijiahao.baidu.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开百家号,无法确认发布结果',
}
}
if (/^\/builder\/rc\/edit\/?$/.test(url.pathname)) {
const draftId = getBaijiahaoDraftId(url)
if (draftId) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认百家号草稿地址',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到百家号编辑器,请检查后保存草稿或发布',
}
}
if (/^\/builder\/(?:rc\/)?(?:content|article|manage)/.test(url.pathname)) {
return {
status: 'publication_uncertain',
url: url.href,
message: '已离开百家号本次编辑器,请打开本次文章核对发布结果',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前百家号页面无法确认是草稿还是已发布文章',
}
}
function fillBaijiahaoContent(title, html, plainText) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
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 => {
const existing = getValue()
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const value = getValue()
if (!value) return
observer.disconnect()
resolve(value)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(getValue())
}, timeout)
})
}
function selectContents(element) {
const ownerDocument = element.ownerDocument
const selection = ownerDocument.defaultView?.getSelection()
const range = ownerDocument.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setEditableText(element, value) {
const ownerDocument = element.ownerDocument
element.focus()
selectContents(element)
ownerDocument.execCommand?.('insertText', false, value)
if (normalizeText(element.textContent) !== normalizeText(value)) element.textContent = value
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
function setEditableHtml(element, nextHtml, nextPlainText) {
const ownerDocument = element.ownerDocument
element.focus()
selectContents(element)
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
if (!inserted || normalizeText(element.textContent).length + element.querySelectorAll('img').length * 12 < 1) {
element.innerHTML = nextHtml
}
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
function findUeditor() {
const instances = window.UE_V2?.instants
if (!instances || typeof instances !== 'object') return undefined
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
}
function findEditorIframe() {
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
const body = iframe.contentDocument?.body
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
})
}
function hasExpectedContent(text, imageCount) {
const 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() {
const titleEditor = await waitForCondition(() => (
document.querySelector('.client_components_titleInput [contenteditable="true"]')
|| document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]')
|| document.querySelector('[class*="titleInput"] [contenteditable="true"]')
|| document.querySelector('[contenteditable="true"][placeholder*="标题"]')
))
if (!titleEditor) return { success: false, error: '未找到百家号标题输入框' }
if (title) {
setEditableText(titleEditor, title)
await sleep(200)
if (normalizeText(titleEditor.textContent) !== normalizeText(title)) {
return { success: false, error: '百家号未确认接收标题' }
}
}
if (!html) return { success: true, method: 'title-only' }
const editor = await waitForCondition(findUeditor, 7000)
if (editor) {
try {
editor.setContent(html)
editor.fireEvent?.('contentchange')
editor.fireEvent?.('selectionchange')
await sleep(500)
const serialized = editor.getContent?.() || ''
const text = editor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
const imageCount = (serialized.match(/<img\b/gi) || []).length
if (hasExpectedContent(text, imageCount)) {
return { success: true, method: 'ueditor', imageCount }
}
} catch {}
}
const iframe = await waitForCondition(findEditorIframe, 7000)
const iframeBody = iframe?.contentDocument?.body
if (iframeBody?.isContentEditable) {
setEditableHtml(iframeBody, html, plainText)
await sleep(500)
const imageCount = iframeBody.querySelectorAll('img').length
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
return { success: true, method: 'iframe', imageCount }
}
}
const contentEditor = Array.from(document.querySelectorAll('[contenteditable="true"]')).find(element => {
return element !== titleEditor && !titleEditor.contains(element) && !element.contains(titleEditor)
})
if (contentEditor) {
setEditableHtml(contentEditor, html, plainText)
await sleep(500)
const imageCount = contentEditor.querySelectorAll('img').length
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
return { success: true, method: 'contenteditable', imageCount }
}
}
return {
success: false,
error: '百家号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectBaijiahaoTask(tab, helpers) {
const currentState = inspectBaijiahaoTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
return url.hostname === 'baijiahao.baidu.com'
&& url.pathname === '/s'
&& Boolean(url.searchParams.get('id'))
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(link.textContent || '')
} catch {
return false
}
})
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(document.body?.innerText || ''),
}
},
})
const observed = result?.[0]?.result
const state = inspectBaijiahaoTaskState(tab.url || '', observed?.publicUrl)
if (state.status === 'filled' && observed?.draftSaved) {
return {
...state,
status: 'draft_saved',
message: '已确认百家号编辑器显示草稿保存成功',
}
}
return state
} catch {
return currentState
}
}
async function syncBaijiahaoContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取百家号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillBaijiahaoContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '百家号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充百家号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
BaijiahaoPlatform,
fillBaijiahaoContent,
inspectBaijiahaoTask,
inspectBaijiahaoTaskState,
parseBaijiahaoPublishedUrl,
syncBaijiahaoContent,
}
+261 -67
View File
@@ -1,4 +1,3 @@
// CSDN 平台配置
const CSDNPlatform = {
id: 'csdn',
name: 'CSDN',
@@ -9,89 +8,284 @@ const CSDNPlatform = {
type: 'csdn',
}
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,
}
+134 -54
View File
@@ -1,28 +1,77 @@
// 平台配置汇总
// 从 @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 { JuejinPlatform, syncJuejinContent } from './juejin.js'
import { CSDNPlatform, inspectCsdnTask, syncCsdnContent } from './csdn.js'
import { inspectJuejinTaskState, JuejinPlatform, syncJuejinContent } from './juejin.js'
import { WechatPlatform, syncWechatContent } from './wechat.js'
import { ZhihuPlatform, syncZhihuContent } from './zhihu.js'
import { ToutiaoPlatform, syncToutiaoContent } from './toutiao.js'
import { SegmentFaultPlatform } from './segmentfault.js'
import {
inspectZhihuTask,
ZhihuPlatform,
syncZhihuContent,
} from './zhihu.js'
import {
inspectToutiaoTask,
ToutiaoPlatform,
syncToutiaoContent,
} from './toutiao.js'
import {
inspectSegmentFaultTask,
SegmentFaultPlatform,
syncSegmentFaultContent,
} from './segmentfault.js'
import { CnblogsPlatform } from './cnblogs.js'
import { OSChinaPlatform } from './oschina.js'
import { CTO51Platform } from './cto51.js'
import { InfoQPlatform } from './infoq.js'
import { JianshuPlatform } from './jianshu.js'
import { BaijiahaoPlatform } from './baijiahao.js'
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
import { TencentCloudPlatform } from './tencentcloud.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,
syncInfoQContent,
} from './infoq.js'
import {
inspectJianshuTask,
JianshuPlatform,
syncJianshuContent,
} from './jianshu.js'
import {
BaijiahaoPlatform,
inspectBaijiahaoTask,
syncBaijiahaoContent,
} from './baijiahao.js'
import {
inspectWangyihaoTask,
WangyihaoPlatform,
syncWangyihaoContent,
} from './wangyihao.js'
import {
inspectTencentCloudTask,
TencentCloudPlatform,
syncTencentCloudContent,
} from './tencentcloud.js'
import { MediumPlatform } from './medium.js'
import { SspaiPlatform } from './sspai.js'
import { SohuPlatform } from './sohu.js'
import { inspectSohuTask, SohuPlatform, syncSohuContent } from './sohu.js'
import { BilibiliPlatform } from './bilibili.js'
import { WeiboPlatform } from './weibo.js'
import { AliyunPlatform } from './aliyun.js'
import {
AliyunPlatform,
inspectAliyunTask,
syncAliyunContent,
} from './aliyun.js'
import { HuaweiCloudPlatform } from './huaweicloud.js'
import { HuaweiDevPlatform } from './huaweidev.js'
import { TwitterPlatform } from './twitter.js'
@@ -35,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,
@@ -45,6 +115,7 @@ const PLATFORMS = [
SegmentFaultPlatform,
CnblogsPlatform,
OSChinaPlatform,
QiehaoPlatform,
CTO51Platform,
InfoQPlatform,
JianshuPlatform,
@@ -70,53 +141,62 @@ 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 'wangyihao'
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,
zhihu: syncZhihuContent,
toutiao: syncToutiaoContent,
wangyihao: syncWangyihaoContent,
baijiahao: syncBaijiahaoContent,
wangyi: syncWangyihaoContent,
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,
toutiao: inspectToutiaoTask,
baijiahao: inspectBaijiahaoTask,
wangyi: inspectWangyihaoTask,
sohu: inspectSohuTask,
infoq: inspectInfoQTask,
tencentcloud: inspectTencentCloudTask,
aliyun: inspectAliyunTask,
segmentfault: inspectSegmentFaultTask,
'51cto': inspectCto51Task,
oschina: inspectOSChinaTask,
qiehao: inspectQiehaoTask,
}
// 导出
export { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
export {
AUTO_FILL_PLATFORM_IDS,
INSPECT_HANDLERS,
PLATFORMS,
LOGIN_CHECK_CONFIG,
SYNC_HANDLERS,
}
+432 -38
View File
@@ -1,58 +1,452 @@
// InfoQ 平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const InfoQPlatform = {
id: 'infoq',
name: 'InfoQ',
icon: 'https://static001.infoq.cn/static/write/img/write-favicon.jpg',
url: 'https://xie.infoq.cn',
// InfoQ 需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write
publishUrl: 'https://xie.infoq.cn/draft/write', // 这个 URL 仅作为占位,实际会被动态替换
publishUrl: 'https://xie.infoq.cn/draft/write',
createDraftApi: 'https://xie.infoq.cn/api/v1/draft/create',
title: 'InfoQ',
type: 'infoq',
}
// InfoQ 内容填充函数
async function fillInfoQContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
// 填充标题
const titleInput = await waitFor(
'input[placeholder*="标题"], .title-input input, input.article-title'
)
if (titleInput) {
setInputValue(titleInput, title)
console.log('[COSE] InfoQ 标题填充成功')
const normalized = value.trim()
return normalized || undefined
}
function parseInfoQPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl, 'https://xie.infoq.cn')
if (url.protocol !== 'https:' || !['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)) {
return null
}
const published = url.pathname.match(/^\/article\/([A-Za-z0-9]+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认 InfoQ 文章公开地址',
}
} catch {
return null
}
}
function getInfoQDraftId(url) {
const draftId = normalizeString(url.pathname.match(/^\/draft\/([A-Za-z0-9_-]+)\/?$/)?.[1])
return ['list', 'manage', 'write'].includes(draftId) ? undefined : draftId
}
function inspectInfoQTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseInfoQPublishedUrl(publicUrl) || parseInfoQPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取 InfoQ 标签页地址' }
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// InfoQ 使用自定义 Vue 编辑器,通过 readMarkdown 方法填充内容
const gkEditor = document.querySelector('.gk-editor')
if (gkEditor && gkEditor.__vue__) {
const vm = gkEditor.__vue__
if (typeof vm.readMarkdown === 'function') {
try {
vm.readMarkdown(contentToFill)
console.log('[COSE] InfoQ readMarkdown 填充成功')
return
} catch (e) {
console.log('[COSE] InfoQ readMarkdown 失败:', e.message)
}
if (url.protocol !== 'https:' || !['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开 InfoQ,无法确认发布结果',
}
}
// 备用方案:尝试 CodeMirror
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] InfoQ CodeMirror 填充成功')
return
if (url.hostname === 'xie.infoq.cn') {
if (/^\/draft\/write\/?$/.test(url.pathname)) {
return {
status: 'filled',
url: url.href,
message: '已打开 InfoQ 新建草稿页,等待创建草稿后填充内容',
}
}
const draftId = getInfoQDraftId(url)
if (draftId) {
if (draftSaved) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认 InfoQ 编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到 InfoQ 编辑器,请等待保存完成或手动保存草稿',
}
}
}
console.log('[COSE] InfoQ 未找到编辑器')
return {
status: 'publication_uncertain',
url: url.href,
message: '当前 InfoQ 页面无法确认是草稿还是已发布文章',
}
}
// 导出
export { InfoQPlatform, fillInfoQContent }
function parseInfoQDraftCreation(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
return normalizeString(data?.id)
|| normalizeString(data?.draftId)
|| normalizeString(data?.draft_id)
}
async function createInfoQDraft() {
try {
const response = await fetch('/api/v1/draft/create', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
const body = await response.json().catch(() => undefined)
const draftId = body?.data?.id || body?.data?.draftId || body?.data?.draft_id
if (!response.ok || body?.code !== 0 || !draftId) {
return {
success: false,
error: body?.message || body?.msg || 'InfoQ 未返回新建草稿 ID',
}
}
return { success: true, draftId: String(draftId) }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'InfoQ 创建草稿失败',
}
}
}
function fillInfoQContent(title, markdown, html, plainText) {
const contentToFill = markdown || ''
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
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, 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 selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setInputValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
}
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedContent(editor) {
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
const imageCount = editor?.querySelectorAll?.('img').length || 0
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) {
const root = editor.closest?.('.gk-editor') || document.querySelector('.gk-editor')
return [
root?.__vue__,
root?.__vueParentComponent?.proxy,
editor.__vue__,
editor.__vueParentComponent?.proxy,
].filter(Boolean)
}
function dispatchHtmlPaste(editor) {
editor.focus()
selectEditorContents(editor)
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', plainText)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
async function fill() {
const titleInput = await waitForElement([
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
'.title-input textarea',
'input.article-title',
'[contenteditable="true"][placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到 InfoQ 标题输入框' }
if (title) {
setInputValue(titleInput, title)
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"]',
'.gk-editor .ProseMirror',
'.ProseMirror[contenteditable="true"]',
'.ProseMirror',
'[contenteditable="true"][role="textbox"]',
], titleInput)
if (!editor) return { success: false, error: '未找到 InfoQ 正文编辑器' }
if (!contentToFill) return { success: true, method: 'title-only' }
for (const candidate of getVueCandidates(editor)) {
for (const method of [candidate.readMarkdown, candidate.editorAPI?.readMarkdown]) {
if (typeof method !== 'function') continue
try {
await Promise.resolve(method.call(method === candidate.readMarkdown ? candidate : candidate.editorAPI, contentToFill))
await sleep(800)
if (hasExpectedContent(editor)) {
return { success: true, method: 'markdown-api', imageCount: editor.querySelectorAll('img').length }
}
} catch {}
}
}
dispatchHtmlPaste(editor)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(500)
}
if (!hasExpectedContent(editor)) {
return {
success: false,
error: 'InfoQ 未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: 'paste-html',
imageCount: editor.querySelectorAll('img').length,
}
}
return fill()
}
async function inspectInfoQTask(tab, helpers) {
const currentState = inspectInfoQTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return ['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)
&& /^\/article\/[A-Za-z0-9]+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|内容已保存|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
return inspectInfoQTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncInfoQContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取 InfoQ 编辑器标签页' }
await helpers.waitForTab(tab.id)
const created = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: createInfoQDraft,
world: 'MAIN',
})
const draft = created?.[0]?.result
if (!draft?.success || !draft.draftId) {
return {
success: false,
message: draft?.error || 'InfoQ 创建草稿失败,请确认当前 Chrome 已登录创作平台',
tabId: tab.id,
}
}
const draftTab = await helpers.chrome.tabs.update(tab.id, {
url: `https://xie.infoq.cn/draft/${encodeURIComponent(draft.draftId)}`,
})
await helpers.waitForTab(draftTab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: draftTab.id },
func: fillInfoQContent,
args: [
content.title,
markdown,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || 'InfoQ 内容填充失败',
tabId: draftTab.id,
}
}
return {
success: true,
message: '已创建并填充 InfoQ 草稿,请检查后等待保存完成或手动发布',
tabId: draftTab.id,
}
}
export {
createInfoQDraft,
fillInfoQContent,
InfoQPlatform,
inspectInfoQTask,
inspectInfoQTaskState,
parseInfoQDraftCreation,
parseInfoQPublishedUrl,
syncInfoQContent,
}
+255 -45
View File
@@ -9,51 +9,261 @@ const JianshuPlatform = {
type: 'jianshu',
}
// 简书内容填充函数
async function fillJianshuContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
// 填充标题 - 简书使用 input._24i7u,需要使用 native setter
const titleInput = await waitFor('input._24i7u, input[class*="title"]')
if (titleInput) {
titleInput.focus()
const inputSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
inputSetter.call(titleInput, title)
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 简书标题填充成功')
} else {
console.log('[COSE] 简书未找到标题输入框')
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 500))
// 简书使用 textarea#arthur-editor 作为 Markdown 编辑器
const editor =
document.querySelector('#arthur-editor') || document.querySelector('textarea._3swFR')
if (editor) {
editor.focus()
const textareaSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
textareaSetter.call(editor, contentToFill)
editor.dispatchEvent(
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' })
)
editor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 简书内容填充成功')
} else {
console.log('[COSE] 简书未找到编辑器')
function parseJianshuPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl, 'https://www.jianshu.com')
if (url.protocol !== 'https:' || !['jianshu.com', 'www.jianshu.com'].includes(url.hostname))
return null
const published = url.pathname.match(/^\/p\/([^/]+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认简书文章公开地址',
}
} catch {
return null
}
}
export { JianshuPlatform, fillJianshuContent }
function inspectJianshuTaskState(rawUrl, publicUrl) {
const published = parseJianshuPublishedUrl(publicUrl) || parseJianshuPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取简书标签页地址' }
}
if (url.protocol !== 'https:' || !['jianshu.com', 'www.jianshu.com'].includes(url.hostname)) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开简书,无法确认发布结果',
}
}
const draft = url.hash.match(/^#\/notebooks\/([^/]+)\/notes\/([^/?#]+)/)
if (url.pathname === '/writer' && draft?.[2]) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draft[2],
message: '已确认简书草稿地址',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: url.pathname === '/writer'
? '简书编辑器尚未进入本次任务文章,请重新打开并填充'
: '当前简书页面无法确认是草稿还是已发布文章',
}
}
async function inspectJianshuTask(tab, helpers) {
const currentState = inspectJianshuTaskState(tab?.url || '')
if (currentState.status !== 'draft_saved' || !tab?.id) return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const links = Array.from(document.querySelectorAll('a[href]'))
const publicLink = links.find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const isPublicArticle = ['jianshu.com', 'www.jianshu.com'].includes(url.hostname)
&& /^\/p\/[^/]+\/?$/.test(url.pathname)
const isCurrentArticleAction = link.target === '_blank'
|| /查看文章|阅读文章|已发布/.test(link.textContent || '')
return isPublicArticle && isCurrentArticleAction
} catch {
return false
}
})
return publicLink ? new URL(publicLink.getAttribute('href'), location.href).href : undefined
},
})
return inspectJianshuTaskState(tab.url || '', result?.[0]?.result)
} catch {
return currentState
}
}
function fillJianshuContent(title, markdown, body) {
const contentToFill = markdown || body || ''
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 = 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: '简书未确认接收标题' }
}
}
if (!contentToFill) return { success: true, method: 'title-only' }
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: '简书未确认接收正文' }
}
return { success: true, method: 'textarea' }
}
return fill()
}
async function syncJianshuContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
const notebooksResponse = await fetch('https://www.jianshu.com/author/notebooks', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!notebooksResponse.ok) {
return { success: false, message: '简书文集读取失败,请确认当前 Chrome 已登录', tabId: tab.id }
}
const notebooks = await notebooksResponse.json()
const notebookId = Array.isArray(notebooks) ? notebooks[0]?.id : undefined
if (!notebookId) {
return { success: false, message: '简书未找到文集,请先创建一个文集', tabId: tab.id }
}
const createResponse = await fetch('https://www.jianshu.com/author/notes', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
notebook_id: String(notebookId),
title: content.title || '无标题',
at_bottom: false,
}),
})
if (!createResponse.ok) {
return { success: false, message: '简书草稿创建失败,请确认登录状态和文集权限', tabId: tab.id }
}
const note = await createResponse.json()
if (!note?.id) {
return { success: false, message: '简书草稿创建结果缺少文章 ID', tabId: tab.id }
}
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${note.id}`
const updatedTab = await chrome.tabs.update(tab.id, { url: targetUrl })
await waitForTab(updatedTab.id)
const result = await chrome.scripting.executeScript({
target: { tabId: updatedTab.id },
func: fillJianshuContent,
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '简书内容填充失败',
tabId: updatedTab.id,
}
}
await new Promise(resolve => setTimeout(resolve, 1000))
return {
success: true,
message: '已同步到简书草稿',
tabId: updatedTab.id,
platformWorkId: String(note.id),
url: targetUrl,
}
}
export {
JianshuPlatform,
fillJianshuContent,
inspectJianshuTask,
inspectJianshuTaskState,
syncJianshuContent,
}
+148 -39
View File
@@ -9,45 +9,159 @@ const JuejinPlatform = {
type: 'juejin',
}
import { injectUtils } from './common.js'
function inspectJuejinTaskState(rawUrl) {
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取掘金标签页地址' }
}
if (url.protocol !== 'https:' || !['juejin.cn', 'www.juejin.cn'].includes(url.hostname)) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开掘金,无法确认发布结果',
}
}
const published = url.pathname.match(/^\/post\/([^/]+)\/?$/)
if (published?.[1]) {
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认掘金文章公开地址',
}
}
const draft = url.pathname.match(/^\/editor\/drafts\/([^/]+)\/?$/)
if (draft?.[1] && draft[1] !== 'new') {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draft[1],
message: '已确认掘金草稿地址',
}
}
if (draft?.[1] === 'new') {
return {
status: 'filled',
url: url.href,
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前掘金页面无法确认是草稿还是已发布文章',
}
}
// 掘金内容填充函数(在页面主世界中执行)
// 注意:需要先调用 injectUtils 注入 window.waitFor
function fillJuejinContent(title, markdown, body) {
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()
@@ -63,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,
@@ -86,4 +195,4 @@ async function syncJuejinContent(tab, content, helpers) {
}
// 导出
export { JuejinPlatform, fillJuejinContent, syncJuejinContent }
export { JuejinPlatform, fillJuejinContent, inspectJuejinTaskState, syncJuejinContent }
+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,
}
+345 -8
View File
@@ -1,19 +1,356 @@
// 搜狐号平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const SohuPlatform = {
id: 'sohu',
name: 'Sohu',
icon: 'https://statics.itc.cn/mp-new/icon/1.1/favicon.ico',
url: 'https://mp.sohu.com',
publishUrl: 'https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?contentStatus=1',
publishUrl: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
title: '搜狐号',
type: 'sohu',
}
// 搜狐号内容填充函数
// 注意:搜狐号由 syncToPlatform 单独处理,此函数作为备用
async function fillSohuContent(content, waitFor) {
console.log('[COSE] 搜狐号由 syncToPlatform 处理')
function parseSohuPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl, 'https://mp.sohu.com')
if (url.protocol !== 'https:' || url.hostname !== 'www.sohu.com') return null
const published = url.pathname.match(/^\/a\/(\d+(?:_\d+)?)\/?(?:\.html)?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认搜狐号文章公开地址',
}
} catch {
return null
}
}
// 导出
export { SohuPlatform, fillSohuContent }
function inspectSohuTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseSohuPublishedUrl(publicUrl) || parseSohuPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取搜狐号标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'mp.sohu.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开搜狐号,无法确认发布结果',
}
}
if (
/^\/mpfe\/v4\/main\/content\/article\/(?:create|edit)\/?$/.test(url.pathname)
|| /^\/mpfe\/v4\/contentManagement\/news\/addarticle\/?$/.test(url.pathname)
) {
if (draftSaved) {
return {
status: 'draft_saved',
url: url.href,
message: '已确认搜狐号编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到搜狐号编辑器,请检查后保存草稿或发布',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前搜狐号页面无法确认是草稿还是已发布文章',
}
}
function fillSohuContent(title, html, plainText) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
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 => {
const existing = getValue()
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const value = getValue()
if (!value) return
observer.disconnect()
resolve(value)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(getValue())
}, timeout)
})
}
function selectEditorContents(editor) {
const ownerDocument = editor.ownerDocument
const selection = ownerDocument.defaultView?.getSelection()
const range = ownerDocument.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setTitleValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = input.ownerDocument.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
}
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function findUeditor() {
const instances = window.UE?.instants || window.UE?.instances
if (!instances || typeof instances !== 'object') return undefined
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
}
function findEditorIframe() {
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
const body = iframe.contentDocument?.body
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
})
}
function findContentEditor(titleInput) {
return Array.from(document.querySelectorAll('[contenteditable="true"], .edui-body')).find(element => {
return element !== titleInput && !titleInput.contains(element) && !element.contains(titleInput)
})
}
function hasExpectedContent(text, imageCount) {
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) {
const ownerDocument = editor.ownerDocument
editor.focus()
selectEditorContents(editor)
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
}
async function fill() {
const titleInput = await waitForCondition(() => {
return [
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
'.title-input textarea',
'[contenteditable="true"][placeholder*="标题"]',
].flatMap(selector => Array.from(document.querySelectorAll(selector))).find(Boolean)
})
if (!titleInput) return { success: false, error: '未找到搜狐号标题输入框' }
if (title) {
setTitleValue(titleInput, title)
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' }
const ueditor = await waitForCondition(findUeditor, 7000)
if (ueditor) {
try {
ueditor.setContent(html)
ueditor.fireEvent?.('contentchange')
ueditor.fireEvent?.('selectionchange')
await sleep(500)
const serialized = ueditor.getContent?.() || ''
const text = ueditor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
const imageCount = (serialized.match(/<img\b/gi) || []).length
if (hasExpectedContent(text, imageCount)) {
return { success: true, method: 'ueditor', imageCount }
}
} catch {}
}
const iframe = await waitForCondition(findEditorIframe, 7000)
const iframeBody = iframe?.contentDocument?.body
if (iframeBody?.isContentEditable) {
setEditableHtml(iframeBody, html, plainText)
await sleep(500)
const imageCount = iframeBody.querySelectorAll('img').length
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
return { success: true, method: 'iframe', imageCount }
}
}
const contentEditor = await waitForCondition(() => findContentEditor(titleInput), 7000)
if (contentEditor) {
setEditableHtml(contentEditor, html, plainText)
await sleep(500)
const imageCount = contentEditor.querySelectorAll('img').length
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
return { success: true, method: 'contenteditable', imageCount }
}
}
return {
success: false,
error: '搜狐号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectSohuTask(tab, helpers) {
const currentState = inspectSohuTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'www.sohu.com'
&& /^\/a\/\d+(?:_\d+)?\/?(?:\.html)?$/.test(url.pathname)
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
visibleText,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectSohuTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
if (state.status === 'filled' && /发布成功|已发布/.test(observed?.visibleText || '')) {
return {
...state,
status: 'publication_uncertain',
message: '搜狐号页面显示发布完成,但未找到公开文章地址,请人工核对后回写结果',
}
}
return state
} catch {
return currentState
}
}
async function syncSohuContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取搜狐号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillSohuContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '搜狐号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充搜狐号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
fillSohuContent,
inspectSohuTask,
inspectSohuTaskState,
parseSohuPublishedUrl,
SohuPlatform,
syncSohuContent,
}
+364 -118
View File
@@ -1,4 +1,3 @@
// 腾讯云开发者平台配置
const TencentCloudPlatform = {
id: 'tencentcloud',
name: 'TencentCloud',
@@ -7,144 +6,391 @@ const TencentCloudPlatform = {
publishUrl: 'https://cloud.tencent.com/developer/article/write-new',
title: '腾讯云开发者社区',
type: 'tencentcloud',
autoFillSupported: true,
}
/**
* 检查当前是否需要切换到 MD 编辑器
* 判断依据:页面中是否存在"切换 MD 编辑器"的按钮
* - 如果存在,说明当前是富文本编辑器,需要切换
* - 如果不存在(显示"切换 富文本 编辑器"),说明已经是 MD 编辑器
* @returns {HTMLElement|null} 返回切换按钮元素,如果已经是 MD 编辑器则返回 null
*/
function findSwitchToMDButton() {
const headerBtns = document.querySelectorAll('.header-btn')
for (const btn of headerBtns) {
// 只有当按钮文本包含"MD"时才需要切换(说明当前是富文本编辑器)
// 如果按钮文本包含"富文本",说明已经是 MD 编辑器,不需要切换
if (btn.textContent.includes('切换') && btn.textContent.includes('MD')) {
return btn
}
}
return null
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
/**
* 确保编辑器处于 Markdown 模式
* 1. 检查是否有"切换 MD 编辑器"按钮
* 2. 如果有,点击切换到 MD 编辑器
* 3. 如果没有,说明已经是 MD 编辑器
* @returns {Promise<boolean>} 是否成功进入 MD 编辑器模式
*/
async function ensureMarkdownEditor() {
// 等待页面加载完成
await new Promise(resolve => setTimeout(resolve, 1000))
function parseTencentCloudPublishedUrl(rawUrl) {
if (!rawUrl) return null
const switchBtn = findSwitchToMDButton()
try {
const url = new URL(rawUrl, 'https://cloud.tencent.com')
if (url.protocol !== 'https:' || url.hostname !== 'cloud.tencent.com') return null
if (switchBtn) {
// 找到了"切换 MD 编辑器"按钮,说明当前是富文本编辑器,需要切换
console.log('[COSE] TencentCloud 检测到富文本编辑器,正在切换到 MD 编辑器...')
switchBtn.click()
const match = url.pathname.match(/^\/developer\/article\/(\d+)\/?$/)
if (!match?.[1]) return null
// 等待切换完成
await new Promise(resolve => setTimeout(resolve, 1500))
// 验证切换是否成功:检查 CodeMirror 是否存在
const cm = document.querySelector('.CodeMirror')
if (cm && cm.CodeMirror) {
console.log('[COSE] TencentCloud 成功切换到 MD 编辑器')
return true
return {
status: 'published',
url: url.href,
platformWorkId: match[1],
message: '已确认腾讯云开发者社区文章公开地址',
}
// 如果 CodeMirror 还没加载,再等待一下
await new Promise(resolve => setTimeout(resolve, 1000))
const cmRetry = document.querySelector('.CodeMirror')
if (cmRetry && cmRetry.CodeMirror) {
console.log('[COSE] TencentCloud 成功切换到 MD 编辑器(延迟加载)')
return true
}
console.error('[COSE] TencentCloud 切换失败:CodeMirror 未加载')
return false
} else {
// 没有找到"切换 MD 编辑器"按钮,说明已经是 MD 编辑器
console.log('[COSE] TencentCloud 当前已是 MD 编辑器')
// 验证 CodeMirror 是否存在
const cm = document.querySelector('.CodeMirror')
if (cm && cm.CodeMirror) {
return true
}
// 等待 CodeMirror 加载
await new Promise(resolve => setTimeout(resolve, 1000))
const cmRetry = document.querySelector('.CodeMirror')
return !!(cmRetry && cmRetry.CodeMirror)
} catch {
return null
}
}
/**
* 获取 CodeMirror 实例
* @param {number} maxWait 最大等待时间(毫秒)
* @returns {Promise<CodeMirror|null>}
*/
async function getCodeMirror(maxWait = 3000) {
const startTime = Date.now()
while (Date.now() - startTime < maxWait) {
const cm = document.querySelector('.CodeMirror')
if (cm && cm.CodeMirror) {
return cm.CodeMirror
function getTencentCloudDraftId(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 inspectTencentCloudTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseTencentCloudPublishedUrl(publicUrl) || parseTencentCloudPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取腾讯云开发者社区标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'cloud.tencent.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开腾讯云开发者社区,无法确认发布结果',
}
await new Promise(resolve => setTimeout(resolve, 200))
}
return null
if (/^\/developer\/article\/(?:write-new|edit)\/?$/.test(url.pathname)) {
const draftId = getTencentCloudDraftId(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: '当前腾讯云开发者社区页面无法确认是草稿还是已发布文章',
}
}
/**
* 腾讯云内容填充函数
* 流程:
* 1. 确保进入 MD 编辑器模式
* 2. 填充标题
* 3. 填充内容到 CodeMirror
*/
async function fillTencentCloudContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
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))
console.log('[COSE] TencentCloud 开始同步...')
// 第一步:确保进入 MD 编辑器模式
const isMarkdownMode = await ensureMarkdownEditor()
if (!isMarkdownMode) {
console.error('[COSE] TencentCloud 错误:无法进入 MD 编辑器模式,请手动切换后重试')
return
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))
}
// 第二步:获取 CodeMirror 实例
const codeMirror = await getCodeMirror(3000)
if (!codeMirror) {
console.error('[COSE] TencentCloud 错误:CodeMirror 未加载,请刷新页面后重试')
return
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)
}
// 第三步:填充标题
const titleInput = document.querySelector('textarea[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] TencentCloud 标题填充成功')
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)
})
}
// 第四步:填充内容到 CodeMirror
codeMirror.setValue(contentToFill)
console.log('[COSE] TencentCloud 内容填充成功')
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 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
}
async function switchToMarkdownEditor() {
const switchButton = Array.from(document.querySelectorAll('.header-btn, button, [role="button"]'))
.find(element => {
const text = element.textContent?.replace(/\s+/g, '') || ''
return text.includes('切换') && /MD|Markdown/i.test(text)
})
if (!switchButton) return
switchButton.click()
await sleep(900)
}
async function fill() {
const titleInput = await waitForElement([
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
'.article-title textarea',
'.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 (!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'], 4500)
const editor = codeMirror?.CodeMirror || getCodeMirror()
if (editor && typeof editor.setValue === 'function') {
editor.focus()
editor.setValue(contentToFill)
await sleep(250)
if (normalizeContent(editor.getValue?.()) === normalizeContent(contentToFill)) {
return { success: true, method: 'codemirror' }
}
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
}
const textarea = await waitForElement([
'textarea[placeholder*="正文"]',
'.markdown-editor textarea',
'.editor-container textarea',
], 3000)
if (!textarea) return { success: false, error: '未找到腾讯云开发者社区 Markdown 编辑器' }
setInputValue(textarea, contentToFill)
await sleep(250)
if (normalizeContent(textarea.value) !== normalizeContent(contentToFill)) {
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
}
return { success: true, method: 'textarea' }
}
return fill()
}
// 导出
export { TencentCloudPlatform, fillTencentCloudContent, ensureMarkdownEditor, getCodeMirror }
async function inspectTencentCloudTask(tab, helpers) {
const currentState = inspectTencentCloudTaskState(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 === 'cloud.tencent.com'
&& /^\/developer\/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 inspectTencentCloudTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncTencentCloudContent(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: fillTencentCloudContent,
args: [content.title, markdown, content.html || '', content.plainText || 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 {
fillTencentCloudContent,
inspectTencentCloudTask,
inspectTencentCloudTaskState,
parseTencentCloudPublishedUrl,
syncTencentCloudContent,
TencentCloudPlatform,
}
+456 -105
View File
@@ -1,4 +1,3 @@
// 今日头条平台配置
const ToutiaoPlatform = {
id: 'toutiao',
name: 'Toutiao',
@@ -9,134 +8,486 @@ const ToutiaoPlatform = {
type: 'toutiao',
}
import { injectUtils } from './common.js'
function parseToutiaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 今日头条内容填充函数(在页面主世界中执行)
function fillToutiaoContentInPage(title, body) {
// 等待满足条件的元素出现
function waitForElement(predicate, timeout = 10000) {
try {
const url = new URL(rawUrl, 'https://mp.toutiao.com')
if (url.protocol !== 'https:' || !['toutiao.com', 'www.toutiao.com'].includes(url.hostname)) {
return null
}
const published = url.pathname.match(/^\/(?:article|item|w)\/([^/?#]+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认今日头条文章公开地址',
}
} catch {
return null
}
}
function getToutiaoDraftId(url) {
for (const key of ['draft_id', 'draftId']) {
const value = url.searchParams.get(key)
if (value?.trim()) return value.trim()
}
return undefined
}
function inspectToutiaoTaskState(rawUrl, publicUrl) {
const published = parseToutiaoPublishedUrl(publicUrl) || parseToutiaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取今日头条标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'mp.toutiao.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开今日头条,无法确认发布结果',
}
}
if (/^\/profile_v4\/graphic\/publish\/?$/.test(url.pathname)) {
const draftId = getToutiaoDraftId(url)
if (draftId) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认今日头条草稿地址',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
}
}
if (/^\/profile_v4\/graphic\/articles\/?$/.test(url.pathname)) {
return {
status: 'publication_uncertain',
url: url.href,
message: '已回到今日头条文章列表,请打开本次文章核对发布结果',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前今日头条页面无法确认是草稿还是已发布文章',
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeUrl(value) {
try {
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
} catch {
return null
}
}
function renderToutiaoInline(value) {
const pattern = /!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g
let html = ''
let cursor = 0
for (const match of value.matchAll(pattern)) {
html += escapeHtml(value.slice(cursor, match.index))
if (match[1]) {
const url = safeUrl(match[2])
html += url
? `<img src="${escapeHtml(url)}" alt="${escapeHtml(match[1])}" />`
: escapeHtml(match[0])
} else if (match[3]) {
const url = safeUrl(match[4])
html += url
? `<a href="${escapeHtml(url)}">${escapeHtml(match[3])}</a>`
: escapeHtml(match[0])
} else if (match[5]) {
html += `<code>${escapeHtml(match[5])}</code>`
} else if (match[6] || match[7]) {
html += `<strong>${escapeHtml(match[6] || match[7])}</strong>`
} else {
html += `<em>${escapeHtml(match[8] || match[9])}</em>`
}
cursor = match.index + match[0].length
}
return html + escapeHtml(value.slice(cursor))
}
function renderToutiaoMarkdown(markdown) {
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n')
const blocks = []
let paragraph = []
let listType
let listItems = []
let codeLines
const flushParagraph = () => {
if (paragraph.length) {
blocks.push(`<p>${paragraph.map(renderToutiaoInline).join('<br />')}</p>`)
paragraph = []
}
}
const flushList = () => {
if (!listType) return
blocks.push(`<${listType}>${listItems.map(item => `<li>${renderToutiaoInline(item)}</li>`).join('')}</${listType}>`)
listType = undefined
listItems = []
}
const flushBlocks = () => {
flushParagraph()
flushList()
}
for (const line of lines) {
if (codeLines) {
if (/^\s*```/.test(line)) {
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
codeLines = undefined
} else {
codeLines.push(line)
}
continue
}
if (/^\s*```/.test(line)) {
flushBlocks()
codeLines = []
continue
}
if (!line.trim()) {
flushBlocks()
continue
}
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
if (heading) {
flushBlocks()
const level = heading[1].length
blocks.push(`<h${level}>${renderToutiaoInline(heading[2])}</h${level}>`)
continue
}
const unordered = line.match(/^\s*[-*+]\s+(.+)$/)
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/)
if (unordered || ordered) {
flushParagraph()
const nextType = unordered ? 'ul' : 'ol'
if (listType && listType !== nextType) flushList()
listType = nextType
listItems.push((unordered || ordered)[1])
continue
}
const quote = line.match(/^\s*>\s?(.*)$/)
if (quote) {
flushBlocks()
blocks.push(`<blockquote>${renderToutiaoInline(quote[1])}</blockquote>`)
continue
}
if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
flushBlocks()
blocks.push('<hr />')
continue
}
if (listType) flushList()
paragraph.push(line)
}
if (codeLines) blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
flushBlocks()
return blocks.join('')
}
function renderToutiaoPlainText(markdown) {
return String(markdown || '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[`*_#>~-]/g, '')
.replace(/\s+/g, '')
}
function fillToutiaoContent(title, 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 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 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 el = predicate()
if (el) return resolve(el)
const existing = findFirst(selectors, excludedElement)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const el = predicate()
if (el) {
observer.disconnect()
resolve(el)
}
})
observer.observe(document.body, { childList: true, subtree: true })
setTimeout(() => {
const element = findFirst(selectors, excludedElement)
if (!element) return
observer.disconnect()
resolve(predicate())
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors, excludedElement))
}, timeout)
})
}
async function fillContent() {
// 填充标题 - 头条使用 textarea
const titleInput = await waitForElement(() =>
document.querySelector('textarea[placeholder*="标题"]')
function setInputValue(input, value) {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
input.focus()
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function dispatchHtmlPaste(editor, html, plainText) {
editor.focus()
selectEditorContents(editor)
if (typeof DataTransfer !== 'undefined' && typeof ClipboardEvent !== 'undefined') {
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', plainText)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
return false
}
async function fill() {
const titleInput = await waitForElement([
'.editor-title textarea',
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到今日头条标题输入框' }
if (title) {
setInputValue(titleInput, title)
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: '未找到今日头条正文编辑器' }
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 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 (titleInput && title) {
titleInput.focus()
// 模拟用户输入
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 头条标题填充成功:', title)
} else {
console.log('[COSE] 头条未找到标题输入框')
if (!hasExpectedContent() && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, contentHtml)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: contentText }))
await sleep(500)
editorText = normalizeComparableText(editor.innerText || editor.textContent)
imageCount = editor.querySelectorAll('img').length
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 500))
if (!hasExpectedContent()) {
return {
success: false,
error: '今日头条未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
// 头条使用 ProseMirror 富文本编辑器
const editor = await waitForElement(() => document.querySelector('.ProseMirror'))
if (editor && body) {
editor.focus()
// 对于 ProseMirror,我们需要更智能的方式来填充内容
// 清空现有内容
editor.innerHTML = ''
// 将内容分割成段落
const lines = body.split('\n').filter(line => line.trim() !== '')
// 使用 document.execCommand 插入内容(ProseMirror 兼容)
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
// 创建 HTML 内容
const htmlContent = lines.map(line => `<p>${line}</p>`).join('')
// 使用 insertHTML 命令
document.execCommand('insertHTML', false, htmlContent)
// 触发事件让 ProseMirror 同步
editor.dispatchEvent(new InputEvent('input', { bubbles: true }))
editor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 头条内容填充成功')
return { success: true }
} else {
console.log('[COSE] 头条未找到编辑器')
return { success: false, error: '未找到编辑器' }
return {
success: true,
method: 'paste-html',
imageCount,
}
}
return fillContent()
return fill()
}
async function inspectToutiaoTask(tab, helpers) {
const currentState = inspectToutiaoTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
return ['toutiao.com', 'www.toutiao.com'].includes(url.hostname)
&& /^\/(?:article|item|w)\/[^/]+\/?$/.test(url.pathname)
&& (link.target === '_blank' || /查看文章|阅读文章|发布成功|已发布/.test(link.textContent || ''))
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectToutiaoTaskState(tab.url || '', observed?.publicUrl)
if (state.status === 'filled' && observed?.draftSaved) {
return {
...state,
status: 'draft_saved',
message: '已确认今日头条编辑器显示草稿保存成功',
}
}
return state
} catch {
return currentState
}
}
/**
* 今日头条同步处理器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncToutiaoContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
if (!tab?.id) return { success: false, message: '无法获取今日头条编辑器标签页' }
// 等待页面加载完成
await waitForTab(tab.id)
// 额外等待一下让编辑器完全加载
await new Promise(resolve => setTimeout(resolve, 2500))
// 先注入公共工具函数
await injectUtils(chrome, tab.id)
// 在页面中执行填充
const result = await chrome.scripting.executeScript({
await helpers.waitForTab(tab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillToutiaoContentInPage,
args: [content.title, content.body || content.markdown || ''],
func: fillToutiaoContent,
args: [
content.title,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已打开头条号并填充内容', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '今日头条内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充今日头条编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
// 导出
export { ToutiaoPlatform, fillToutiaoContentInPage, syncToutiaoContent }
export {
ToutiaoPlatform,
fillToutiaoContent,
inspectToutiaoTask,
inspectToutiaoTaskState,
parseToutiaoPublishedUrl,
renderToutiaoMarkdown,
renderToutiaoPlainText,
syncToutiaoContent,
}
+314 -88
View File
@@ -1,117 +1,343 @@
// 网易号平台配置
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
const WangyihaoPlatform = {
id: 'wangyihao',
id: 'wangyi',
name: 'Wangyihao',
icon: 'https://static.ws.126.net/163/f2e/news/yxybd_pc/resource/static/share-icon.png',
url: 'https://mp.163.com',
publishUrl: 'https://mp.163.com/#/article-publish',
title: '网易号',
type: 'wangyihao',
type: 'wangyi',
}
import { injectUtils } from './common.js'
function parseWangyihaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 网易号内容填充函数(在页面主世界中执行)
// 网易号使用剪贴板 HTML 粘贴到 Draft.js 编辑器
function fillWangyihaoContent(title, htmlBody) {
async function fill() {
// 1. 等待并填充标题 - 网易号使用 textarea.netease-textarea
const titleInput =
(await window.waitFor('textarea.netease-textarea', 10000)) ||
(await window.waitFor('textarea[placeholder*="标题"]', 3000))
try {
const url = new URL(rawUrl, 'https://mp.163.com')
if (url.protocol !== 'https:' || url.hostname !== 'www.163.com') return null
if (titleInput && title) {
titleInput.focus()
// 使用 native setter 来绕过 React 的受控组件
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
// 触发 React 能识别的事件
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 网易号标题已填充')
} else {
console.log('[COSE] 网易号未找到标题输入框')
const published = url.pathname.match(/^\/dy\/article\/([A-Za-z0-9_-]+)\.html$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认网易号文章公开地址',
}
} catch {
return null
}
}
function inspectWangyihaoTaskState(rawUrl, publicUrl, draftSaved = false) {
const published = parseWangyihaoPublishedUrl(publicUrl) || parseWangyihaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取网易号标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'mp.163.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开网易号,无法确认发布结果',
}
}
if (
/^#\/(?:article-publish|article-edit)(?:[/?]|$)/.test(url.hash)
|| /^\/post\/submit\/post\/?$/.test(url.pathname)
) {
if (draftSaved) {
return {
status: 'draft_saved',
url: url.href,
message: '已确认网易号编辑器显示草稿保存成功',
}
}
// 2. 等待 Draft.js 编辑器出现
const editor =
(await window.waitFor('.public-DraftEditor-content', 10000)) ||
(await window.waitFor('[contenteditable="true"]', 3000))
return {
status: 'filled',
url: url.href,
message: '内容已填充到网易号编辑器,请检查后保存草稿或发布',
}
}
if (editor && htmlBody) {
editor.focus()
return {
status: 'publication_uncertain',
url: url.href,
message: '当前网易号页面无法确认是草稿还是已发布文章',
}
}
// 清空 Draft.js 占位符
const placeholder = editor.querySelector('[data-text="true"]')
if (placeholder && placeholder.textContent.includes('请输入正文')) {
editor.innerHTML = ''
function fillWangyihaoContent(title, html, plainText) {
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
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
.flatMap(selector => Array.from(document.querySelectorAll(selector)))
.find(element => element !== exclude)
}
function waitForElement(selectors, exclude, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors, exclude)
if (existing) {
resolve(existing)
return
}
// 通过 paste 事件注入 HTML 内容
const dt = new DataTransfer()
dt.setData('text/html', htmlBody)
dt.setData('text/plain', htmlBody.replace(/<[^>]*>/g, ''))
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt,
const observer = new MutationObserver(() => {
const element = findFirst(selectors, exclude)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors, exclude))
}, timeout)
})
}
editor.dispatchEvent(pasteEvent)
console.log('[COSE] 网易号内容已通过 paste 事件注入')
return { success: true }
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setTitleValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
console.log('[COSE] 网易号未找到编辑器元素')
return { success: false, error: 'Editor not found' }
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
}
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function dispatchHtmlPaste(editor, nextHtml, nextPlainText) {
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', nextHtml)
clipboardData.setData('text/plain', nextPlainText)
editor.focus()
selectEditorContents(editor)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
function hasExpectedContent(editor) {
const editorText = normalizeComparableText(editor.innerText || editor.textContent)
const imageCount = editor.querySelectorAll('img').length
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() {
const titleInput = await waitForElement([
'textarea.netease-textarea',
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
'[contenteditable="true"][placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到网易号标题输入框' }
if (title) {
setTitleValue(titleInput, title)
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' }
const editor = await waitForElement([
'.public-DraftEditor-content[contenteditable="true"]',
'.public-DraftEditor-content',
'.DraftEditor-root [contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
'[contenteditable="true"][data-contents="true"]',
], titleInput)
if (!editor) return { success: false, error: '未找到网易号正文编辑器' }
dispatchHtmlPaste(editor, html, plainText)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
await sleep(500)
}
if (!hasExpectedContent(editor)) {
return {
success: false,
error: '网易号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: 'paste-html',
imageCount: editor.querySelectorAll('img').length,
}
}
return fill()
}
/**
* 网易号同步处理器
* 网易号使用剪贴板 HTML 粘贴到 Draft.js 编辑器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown, wechatHtml }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncWangyihaoContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
async function inspectWangyihaoTask(tab, helpers) {
const currentState = inspectWangyihaoTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
// 等待页面加载完成(waitForTab 使用 chrome.tabs.onUpdated 监听)
await waitForTab(tab.id)
// 先注入公共工具函数(waitFor 使用 MutationObserver
await injectUtils(chrome, tab.id)
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 网易号 HTML 内容长度:', htmlContent?.length || 0)
// 在页面中执行:填充标题和粘贴 HTML 内容
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillWangyihaoContent,
args: [content.title, htmlContent],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已同步到网易号', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '网易号内容填充失败', tabId: tab.id }
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'www.163.com'
&& /^\/dy\/article\/[A-Za-z0-9_-]+\.html$/.test(url.pathname)
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
visibleText,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectWangyihaoTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
if (state.status === 'filled' && /发布成功|已发布/.test(observed?.visibleText || '')) {
return {
...state,
status: 'publication_uncertain',
message: '网易号页面显示发布完成,但未找到公开文章地址,请人工核对后回写结果',
}
}
return state
} catch {
return currentState
}
}
// 导出
export { WangyihaoPlatform, fillWangyihaoContent, syncWangyihaoContent }
async function syncWangyihaoContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取网易号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillWangyihaoContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '网易号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充网易号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
WangyihaoPlatform,
fillWangyihaoContent,
inspectWangyihaoTask,
inspectWangyihaoTaskState,
parseWangyihaoPublishedUrl,
syncWangyihaoContent,
}
+270 -378
View File
@@ -1,4 +1,3 @@
// 知乎平台配置
const ZhihuPlatform = {
id: 'zhihu',
name: 'Zhihu',
@@ -9,426 +8,319 @@ const ZhihuPlatform = {
type: 'zhihu',
}
import { injectUtils } from './common.js'
function parseZhihuPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 知乎内容填充函数(在页面主世界中执行)
// 知乎现在支持直接粘贴 Markdown,然后弹窗提示转换
// 注意:需要先调用 injectUtils 注入 window.waitFor
function fillZhihuContent(title, markdown) {
// 等待满足条件的元素出现(使用 MutationObserver
function waitForElement(predicate, timeout = 10000) {
try {
const url = new URL(rawUrl, 'https://zhuanlan.zhihu.com')
if (url.protocol !== 'https:' || url.hostname !== 'zhuanlan.zhihu.com') return null
const published = url.pathname.match(/^\/p\/([^/?#]+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认知乎文章公开地址',
}
} catch {
return null
}
}
function inspectZhihuTaskState(rawUrl, publicUrl) {
const published = parseZhihuPublishedUrl(publicUrl) || parseZhihuPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取知乎标签页地址' }
}
if (
url.protocol !== 'https:'
|| !['zhuanlan.zhihu.com', 'www.zhihu.com'].includes(url.hostname)
) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开知乎,无法确认发布结果',
}
}
if (url.hostname === 'zhuanlan.zhihu.com') {
const draft = url.pathname.match(/^\/write\/([^/?#]+)\/?$/)
if (draft?.[1]) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draft[1],
message: '已确认知乎草稿地址',
}
}
if (/^\/write\/?$/.test(url.pathname)) {
return {
status: 'filled',
url: url.href,
message: '内容已填充到知乎新建编辑器,请检查后保存草稿或发布',
}
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前知乎页面无法确认是草稿还是已发布文章',
}
}
function fillZhihuContent(title, markdown, body) {
const contentToFill = markdown || body || ''
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
const normalizeComparableText = value => String(value || '')
.normalize('NFKC')
.replace(/[^\p{L}\p{N}]+/gu, '')
function markdownToComparableText(value) {
return normalizeComparableText(String(value || '')
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/<[^>]+>/g, ''))
}
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 el = predicate()
if (el) return resolve(el)
const existing = findFirst(selectors, excludedElement)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const el = predicate()
if (el) {
observer.disconnect()
resolve(el)
}
})
observer.observe(document.body, { childList: true, subtree: true })
const element = findFirst(selectors, excludedElement)
if (!element) return
setTimeout(() => {
observer.disconnect()
resolve(predicate())
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors, excludedElement))
}, timeout)
})
}
// 等待按钮出现并点击
async function waitAndClickButton(textMatcher, timeout = 5000) {
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const buttons = document.querySelectorAll('button')
for (const btn of buttons) {
if (textMatcher(btn.textContent)) {
btn.click()
console.log('[COSE] 已点击按钮:', btn.textContent)
return true
}
function setInputValue(input, value) {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
input.focus()
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function clearEditor(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
document.execCommand('delete', false)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
inputType: 'deleteContentBackward',
}))
}
async function clickMarkdownParser() {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
const button = Array.from(document.querySelectorAll('button')).find(candidate => {
if (candidate.offsetParent === null || candidate.disabled) return false
const text = normalizeText(candidate.textContent)
return text === '确认并解析' || text === '确认并转换'
})
if (button) {
button.click()
return true
}
await new Promise(resolve => setTimeout(resolve, 200))
await sleep(150)
}
return false
}
async function fillContent() {
// 第一步:等待知乎编辑器完全加载(避免"草稿加载中"提示)
await new Promise(resolve => setTimeout(resolve, 2000))
// 第二步:填充标题
async function fillTitle() {
const titleInput = await window.waitFor('textarea[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
// 使用 nativeInputValueSetter 确保 React 识别变更
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)?.set
if (nativeSetter) {
nativeSetter.call(titleInput, title)
} else {
titleInput.value = title
}
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 知乎标题填充成功')
async function fill() {
const titleInput = await waitForElement([
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
'.WriteIndex-titleInput textarea',
'.WriteIndex-titleInput input',
])
if (!titleInput) {
return { success: false, error: '未找到知乎标题输入框' }
}
if (title) {
setInputValue(titleInput, title)
await sleep(200)
if (normalizeText(titleInput.value) !== normalizeText(title)) {
return { success: false, error: '知乎未确认接收标题' }
}
}
// 先填充标题
await fillTitle()
// 再等待一下确保标题已保存
await new Promise(resolve => setTimeout(resolve, 500))
// 第三步:找到并激活知乎编辑器
const editorSelectors = [
const editor = await waitForElement([
'.public-DraftEditor-content[contenteditable="true"]',
'.public-DraftEditor-content',
'[contenteditable="true"]',
'.DraftEditor-root',
]
let editor = null
for (const selector of editorSelectors) {
editor = document.querySelector(selector)
if (editor) break
}
'.DraftEditor-root [contenteditable="true"]',
'.WriteIndex-content [contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
], titleInput)
if (!editor) {
console.log('[COSE] 未找到知乎编辑器')
return { success: false, error: 'Editor not found' }
return { success: false, error: '未找到知乎正文编辑器' }
}
// 激活编辑器:模拟真实点击序列
const rect = editor.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
// 触发鼠标事件序列激活编辑器
for (const eventType of ['mousedown', 'mouseup', 'click']) {
const event = new MouseEvent(eventType, {
bubbles: true,
cancelable: true,
view: window,
clientX: centerX,
clientY: centerY,
button: 0,
})
editor.dispatchEvent(event)
}
// 聚焦编辑器
editor.focus()
// 清空现有内容
document.execCommand('selectAll', false)
document.execCommand('delete', false)
// 等待编辑器状态更新
await new Promise(resolve => setTimeout(resolve, 100))
// 第三步:通过剪贴板 + 键盘事件模拟真实粘贴
// 这是触发知乎 Markdown 检测弹窗的关键方法
const contentToFill = markdown || ''
if (!contentToFill) {
console.log('[COSE] 没有 Markdown 内容需要填充')
await fillTitle()
return { success: true, method: 'empty' }
return { success: true, method: 'title-only' }
}
try {
// 使用 ClipboardEvent 模拟粘贴 - 这是触发 Markdown 检测弹窗的关键
// execCommand('insertText') 不会触发弹窗
// 检查浏览器兼容性
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') {
throw new Error('浏览器不支持 DataTransfer 或 ClipboardEvent')
}
const dt = new DataTransfer()
dt.setData('text/plain', contentToFill)
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt,
})
editor.focus()
const dispatched = editor.dispatchEvent(pasteEvent)
console.log('[COSE] 已触发 ClipboardEventdispatched:', dispatched)
// 等待 Markdown 检测弹窗出现并点击"确认并解析"
await new Promise(resolve => setTimeout(resolve, 500))
const parseClicked = await waitAndClickButton(text => text.includes('确认并解析'), 5000)
if (parseClicked) {
console.log('[COSE] 已点击"确认并解析"')
// 等待解析完成并点击"确认"
await new Promise(resolve => setTimeout(resolve, 500))
const confirmClicked = await waitAndClickButton(text => text === '确认', 5000)
if (confirmClicked) {
console.log('[COSE] 已点击"确认"Markdown 解析完成')
}
} else {
console.log('[COSE] 未检测到 Markdown 弹窗')
}
} catch (err) {
console.log('[COSE] 内容插入失败:', err.message || err)
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') {
return { success: false, error: '当前浏览器无法向知乎编辑器粘贴 Markdown' }
}
// 等待内容渲染
await new Promise(resolve => setTimeout(resolve, 300))
editor.focus()
clearEditor(editor)
return { success: true, method: 'paste-markdown' }
const clipboardData = new DataTransfer()
clipboardData.setData('text/plain', contentToFill)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
const parsed = await clickMarkdownParser()
await sleep(parsed ? 1000 : 600)
const 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: '知乎未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: parsed ? 'markdown-parse' : 'paste',
}
}
return fillContent()
return fill()
}
/**
* 知乎同步处理器
* 知乎现在支持直接粘贴 Markdown,然后弹窗提示转换
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncZhihuContent(tab, content, helpers) {
const { waitForTab } = helpers
async function inspectZhihuTask(tab, helpers) {
const currentState = inspectZhihuTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
// 等待页面加载完成(waitForTab 使用 chrome.tabs.onUpdated 监听)
await waitForTab(tab.id)
// 激活知乎标签页(避免后台标签页限制导致填充失败)
try {
await chrome.tabs.update(tab.id, { active: true })
console.log('[COSE] 已激活知乎标签页')
// 等待标签页激活完成
await new Promise(resolve => setTimeout(resolve, 500))
} catch (err) {
console.log('[COSE] 激活标签页失败:', err.message || err)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const link = Array.from(document.querySelectorAll('a[href]')).find(candidate => {
try {
const url = new URL(candidate.getAttribute('href'), location.href)
if (url.hostname !== 'zhuanlan.zhihu.com' || !/^\/p\/[^/]+\/?$/.test(url.pathname)) {
return false
}
return /查看文章|阅读文章|发布成功|已发布/.test(candidate.textContent || '')
} catch {
return false
}
})
return link ? new URL(link.getAttribute('href'), location.href).href : undefined
},
})
return inspectZhihuTaskState(tab.url || '', result?.[0]?.result)
} catch {
return currentState
}
}
async function syncZhihuContent(tab, content, helpers) {
if (!tab?.id) {
return { success: false, message: '无法获取知乎编辑器标签页' }
}
// 先注入公共工具函数(waitFor 使用 MutationObserver
await injectUtils(globalThis.chrome, tab.id)
// 在页面中执行内容填充
const result = await globalThis.chrome.scripting.executeScript({
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillZhihuContent,
args: [content.title, content.markdown],
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
// 等待 2 秒确保内容已保存
await new Promise(resolve => setTimeout(resolve, 2000))
// 等待图片上传完成后再刷新
console.log('[COSE] 开始监听图片上传请求...')
const uploadComplete = await waitForImageUploadComplete(tab.id)
if (uploadComplete) {
console.log('[COSE] 图片上传完成,准备刷新页面')
try {
if (chrome?.tabs && tab?.id) {
await chrome.tabs.reload(tab.id, { bypassCache: false })
console.log('[COSE] 已模拟用户刷新知乎页面')
} else {
console.log('[COSE] chrome.tabs 或 tab.id 不可用,跳过刷新')
}
} catch (err) {
console.log('[COSE] 刷新页面失败:', err.message || err)
}
} else {
console.log('[COSE] 未检测到图片上传请求或超时,跳过刷新')
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '知乎内容填充失败',
tabId: tab.id,
}
}
return { success: true, message: '已打开知乎并同步内容', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '内容同步失败', tabId: tab.id }
return {
success: true,
message: '已打开并填充知乎编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
/**
* 等待图片上传完成
* @param {number} tabId - 标签页 ID
* @param {number} timeout - 超时时间(毫秒)
* @returns {Promise<boolean>}
*/
async function waitForImageUploadComplete(tabId, timeout = 30000) {
const startTime = Date.now()
// 在页面中注入监听脚本
const result = await globalThis.chrome.scripting.executeScript({
target: { tabId: tabId },
func: () => {
return new Promise(resolve => {
const pendingUploads = new Map() // uploadId -> { url, completed }
let hasUploadRequests = false
let lastUploadTime = 0
// 检查是否所有上传都完成
const checkAllComplete = () => {
if (pendingUploads.size === 0) return false
for (const [id, info] of pendingUploads) {
if (!info.completed) return false
}
return true
}
// 监听 fetch 请求
const originalFetch = window.fetch
window.fetch = function (...args) {
const url = args[0]
const options = args[1] || {}
// 检测图片上传请求(知乎的图片上传通常包含这些特征)
const isImageUpload =
typeof url === 'string' &&
(url.includes('/api/v4/images') ||
url.includes('/api/v4/upload') ||
url.includes('upload') ||
(options.method === 'POST' && url.includes('zhihu.com')))
if (isImageUpload) {
hasUploadRequests = true
lastUploadTime = Date.now()
const uploadId = Date.now() + Math.random()
pendingUploads.set(uploadId, { url, completed: false })
console.log('[COSE] 检测到图片上传请求:', url, uploadId)
}
return originalFetch
.apply(this, args)
.then(response => {
if (isImageUpload) {
console.log('[COSE] 图片上传请求完成:', url, response.status)
// 标记为已完成
for (const [id, info] of pendingUploads) {
if (info.url === url) {
info.completed = true
break
}
}
}
return response
})
.catch(error => {
if (isImageUpload) {
console.log('[COSE] 图片上传请求失败:', url, error)
// 即使失败也标记为已完成(有反馈结果)
for (const [id, info] of pendingUploads) {
if (info.url === url) {
info.completed = true
break
}
}
}
throw error
})
}
// 监听 XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open
const originalSend = XMLHttpRequest.prototype.send
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._url = url
this._method = method
return originalOpen.apply(this, [method, url, ...rest])
}
XMLHttpRequest.prototype.send = function (...args) {
const isImageUpload =
this._url &&
(this._url.includes('/api/v4/images') ||
this._url.includes('/api/v4/upload') ||
this._url.includes('upload') ||
(this._method === 'POST' && this._url.includes('zhihu.com')))
if (isImageUpload) {
hasUploadRequests = true
lastUploadTime = Date.now()
const uploadId = Date.now() + Math.random()
pendingUploads.set(uploadId, { url: this._url, completed: false })
console.log('[COSE] 检测到图片上传 XHR:', this._url, uploadId)
this.addEventListener('loadend', () => {
console.log('[COSE] 图片上传 XHR 完成:', this._url, this.status)
// 标记为已完成
for (const [id, info] of pendingUploads) {
if (info.url === this._url) {
info.completed = true
break
}
}
})
}
return originalSend.apply(this, args)
}
// 定期检查是否所有上传都完成
const checkTimer = setInterval(() => {
// 如果没有检测到任何上传请求,说明可能没有图片需要上传
if (!hasUploadRequests) {
console.log('[COSE] 未检测到图片上传请求')
clearInterval(checkTimer)
resolve(true)
return
}
// 如果所有上传都完成,并且距离最后一个上传请求已经过去2秒(确保没有新请求)
if (checkAllComplete() && Date.now() - lastUploadTime > 2000) {
console.log('[COSE] 所有图片上传请求已完成')
clearInterval(checkTimer)
resolve(true)
return
}
}, 500)
// 10秒后如果没有检测到上传请求,认为没有图片需要上传
setTimeout(() => {
if (!hasUploadRequests) {
console.log('[COSE] 10秒内未检测到上传请求,认为无图片')
clearInterval(checkTimer)
resolve(true)
}
}, 10000)
// 超时后无论如何都返回
setTimeout(() => {
console.log('[COSE] 等待图片上传超时')
clearInterval(checkTimer)
resolve(true) // 即使超时也刷新
}, timeout)
})
},
world: 'MAIN',
})
// 等待监听结果
const uploadResult = result?.[0]?.result
console.log('[COSE] 图片上传监听结果:', uploadResult)
// 给一个额外的缓冲时间,确保图片已经完全加载和渲染
await new Promise(resolve => setTimeout(resolve, 2000))
return uploadResult !== false
export {
ZhihuPlatform,
fillZhihuContent,
inspectZhihuTask,
inspectZhihuTaskState,
syncZhihuContent,
}
// 导出
export { ZhihuPlatform, fillZhihuContent, syncZhihuContent }
+11
View File
@@ -0,0 +1,11 @@
const RETURNABLE_PLATFORM_TASK_STATUSES = new Set([
'filled',
'draft_saved',
'published',
])
function canReturnExistingPlatformTaskState(state) {
return Boolean(state?.url && RETURNABLE_PLATFORM_TASK_STATUSES.has(state.status))
}
export { canReturnExistingPlatformTaskState }
+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
}
}
+50 -10
View File
@@ -15,6 +15,7 @@ export const JuejinLoginConfig = {
method: 'GET',
checkLogin: response => response?.err_no === 0 && response?.data?.user_id,
getUserInfo: response => ({
platformUid: response?.data?.user_id,
username: response?.data?.user_name,
avatar: response?.data?.avatar_large,
}),
@@ -26,31 +27,70 @@ export const ZhihuLoginConfig = {
method: 'GET',
checkLogin: response => response?.id,
getUserInfo: response => ({
platformUid: response?.id,
username: response?.name,
avatar: response?.avatar_url,
}),
}
// 头条号
export function parseToutiaoAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : response
const user = data?.user && typeof data.user === 'object' ? data.user : undefined
const platformUid = [
data?.user_id,
data?.uid,
data?.creator_id,
data?.account_id,
data?.id,
user?.user_id,
user?.uid,
user?.id,
].find(value => typeof value === 'string' || typeof value === 'number')
return {
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
username: data?.name || data?.nickname || user?.name || user?.nickname,
avatar: data?.avatar_url || data?.avatar || user?.avatar_url || user?.avatar,
}
}
export const ToutiaoLoginConfig = {
api: 'https://mp.toutiao.com/mp/agw/creator_center/user_info?app_id=1231',
method: 'GET',
checkLogin: response => response?.code === 0 && response?.name,
getUserInfo: response => ({
username: response?.name,
avatar: response?.avatar_url,
}),
checkLogin: response => response?.code === 0 && Boolean(parseToutiaoAccount(response).platformUid),
getUserInfo: parseToutiaoAccount,
}
// 百家号
export function parseBaijiahaoAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : response
const user = data?.user && typeof data.user === 'object' ? data.user : undefined
const platformUid = [
user?.user_id,
user?.userId,
user?.userid,
user?.uid,
user?.uk,
user?.id,
data?.user_id,
data?.uid,
data?.uk,
data?.id,
].find(value => typeof value === 'string' || typeof value === 'number')
return {
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
username: user?.name || user?.nickname || data?.name,
avatar: user?.avatar || user?.avatar_url || data?.avatar,
}
}
export const BaijiahaoLoginConfig = {
api: 'https://baijiahao.baidu.com/builder/app/appinfo',
method: 'GET',
checkLogin: response => response?.errno === 0 && response?.data?.user?.name,
getUserInfo: response => ({
username: response?.data?.user?.name,
avatar: response?.data?.user?.avatar,
}),
checkLogin: response => response?.errno === 0 && Boolean(parseBaijiahaoAccount(response).platformUid),
getUserInfo: parseBaijiahaoAccount,
}
// 抖音
+3 -3
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,14 +46,14 @@ const PLATFORM_DETECTORS = {
qianfan: detectQianfanUser,
twitter: detectTwitterUser,
bilibili: detectBilibiliUser,
cto51: detectCTO51User,
'51cto': detectCto51User,
jianshu: detectJianshuUser,
segmentfault: detectSegmentFaultUser,
infoq: detectInfoQUser,
modelscope: detectModelScopeUser,
volcengine: detectVolcengineUser,
cnblogs: detectCnblogsUser,
wangyihao: detectWangyihaoUser,
wangyi: detectWangyihaoUser,
douban: detectDoubanUser,
}
@@ -1,36 +1,74 @@
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
}
/**
* Aliyun Developer platform detection logic
* Strategy:
* 1. Check login_aliyunid_ticket cookie
* 2. Call getUser API for username/avatar
* 3. Convert avatar to base64 to bypass CORS/ORB
* 账号绑定只接受阿里云开发者接口返回的稳定 ID。
* 昵称和头像只能用于展示,绝不能作为发布任务的账号标识。
*/
export async function detectAliyunUser() {
try {
const ticketCookie = await chrome.cookies.get({
url: 'https://developer.aliyun.com',
name: 'login_aliyunid_ticket',
})
if (!ticketCookie || !ticketCookie.value) return { loggedIn: false }
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?.uccId)
|| normalizeString(account?.ucc_id)
|| normalizeString(account?.userId)
|| normalizeString(account?.user_id)
|| normalizeString(account?.uid)
|| normalizeString(account?.aliuid)
|| normalizeString(account?.aliUid)
|| normalizeString(account?.id)
const response = await fetch('https://developer.aliyun.com/developer/api/my/user/getUser', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
const data = await response.json()
if (data.success && data.data?.nickname) {
let avatar = data.data.avatar || ''
if (avatar) {
avatar = await convertAvatarToBase64(avatar, 'https://developer.aliyun.com/')
}
return { loggedIn: true, username: data.data.nickname, avatar }
}
return { loggedIn: false }
} catch (e) {
return { loggedIn: false }
return {
platformUid,
username: normalizeString(account?.nickname)
|| normalizeString(account?.userName)
|| normalizeString(account?.name)
|| '',
avatar: normalizeString(account?.avatar) || normalizeString(account?.avatarUrl) || '',
}
}
/**
* 仅在用户当前 Chrome 的阿里云页面上下文中调用账号接口。
* 不读取、复制或上传 Cookie,浏览器只向原站自动携带已有登录态。
*/
async function detectAliyunUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '阿里云账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://developer.aliyun.com/',
'https://developer.aliyun.com/developer/api/my/user/getUser',
{
responseType: 'json',
method: 'GET',
},
)
const account = parseAliyunAccount(response?.body)
if (
response?.status < 200
|| response?.status >= 300
|| response?.body?.success !== true
|| !account.platformUid
) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '阿里云账号检测失败',
}
}
}
export { detectAliyunUser, parseAliyunAccount }
@@ -1,69 +1,80 @@
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 getCsdnBlogSlug(value) {
const blogUrl = normalizeString(value)
if (!blogUrl) return undefined
/**
* CSDN platform detection logic
* Strategy:
* 1. Check 'UserName' cookie (reliable indicator of login)
* 2. Use 'UserNick' cookie for display name (UserName is the user ID, UserNick is the display name)
* 3. If logged in, fetch public blog page to get avatar
*/
export async function detectCSDNUser() {
try {
console.log('[COSE] CSDN Detection: Starting cookie check')
const userNameCookie = await chrome.cookies.get({
url: 'https://www.csdn.net',
name: 'UserName',
})
const url = new URL(blogUrl)
if (url.protocol !== 'https:' || url.hostname !== 'blog.csdn.net') return undefined
if (userNameCookie && userNameCookie.value) {
const userId = userNameCookie.value
console.log(`[COSE] CSDN UserName cookie found: ${userId}`)
// UserNick cookie contains the display name (e.g. 'xxxxxx')
const userNickCookie = await chrome.cookies.get({
url: 'https://www.csdn.net',
name: 'UserNick',
})
const username =
userNickCookie && userNickCookie.value ? decodeURIComponent(userNickCookie.value) : userId
console.log(`[COSE] CSDN display name: ${username}`)
let avatar = ''
try {
// Fetch public blog page for avatar
const blogUrl = `https://blog.csdn.net/${userId}`
const blogResp = await fetch(blogUrl, { method: 'GET' })
const blogHtml = await blogResp.text()
// Extract avatar
const avatarMatch =
blogHtml.match(
/<img[^>]*src=["'](https:\/\/(?:profile|i-avatar)\.csdnimg\.cn\/[^"']+)["']/i
) || blogHtml.match(/<img[^>]*class=["']avatar[^"']*["'][^>]*src=["']([^"']+)["']/i)
if (avatarMatch) {
avatar = avatarMatch[1]
}
} catch (e) {
console.warn('[COSE] CSDN Avatar fetch failed:', e)
}
// Convert csdnimg.cn avatar to base64 data URL to bypass CORS/ORB
if (avatar && avatar.includes('csdnimg.cn')) {
avatar = await convertAvatarToBase64(avatar, 'https://blog.csdn.net/')
}
return {
loggedIn: true,
username: username,
avatar: avatar,
}
}
console.log('[COSE] CSDN: No login detected')
return { loggedIn: false }
} catch (e) {
console.error('[COSE] CSDN Detection Error:', e)
return { loggedIn: false, error: e.message }
return normalizeString(url.pathname.split('/').filter(Boolean)[0])
} catch {
return undefined
}
}
/**
* 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 编辑器页面自带的签名请求客户端。
* 不读取、复制或上传 Cookie、Token 或 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,41 +1,66 @@
import { convertAvatarToBase64 } from '../utils.js'
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
/**
* InfoQ platform detection logic
* Strategy: POST to /public/v1/user/get_user API to get user info
* The old /public/v1/my/menu endpoint returns 404.
* InfoQ 账号识别只接受用户接口返回的稳定 UID。
* 昵称和头像只用于展示,不能作为发布任务绑定依据。
*/
export async function detectInfoQUser() {
function parseInfoQAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
const platformUid = normalizeString(data?.uid)
|| normalizeString(data?.userId)
|| normalizeString(data?.user_id)
|| normalizeString(data?.id)
return {
platformUid,
username: normalizeString(data?.nickname) || normalizeString(data?.name) || '',
avatar: normalizeString(data?.avatar) || normalizeString(data?.avatarUrl) || '',
}
}
/**
* 在当前 Chrome 的 InfoQ 页面上下文中调用用户接口。
* 不读取、复制或上传 Cookie;浏览器仅在站点上下文中携带已有登录态。
*/
async function detectInfoQUser() {
try {
console.log('[COSE] InfoQ Detection: Starting')
const response = await fetch('https://www.infoq.cn/public/v1/user/get_user', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: 'InfoQ 账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://www.infoq.cn/',
'https://www.infoq.cn/public/v1/user/get_user',
{
responseType: 'json',
method: 'POST',
},
body: JSON.stringify({}),
})
)
const account = parseInfoQAccount(response?.body)
if (!response.ok) return { loggedIn: false }
const json = await response.json()
if (json?.code !== 0 || !json?.data?.uid) {
console.log('[COSE] InfoQ: Not logged in', json?.code)
if (
response?.status < 200
|| response?.status >= 300
|| response?.body?.code !== 0
|| !account.platformUid
) {
return { loggedIn: false }
}
const username = json.data.nickname || ''
let avatar = json.data.avatar || ''
if (avatar && avatar.includes('geekbang.org')) {
avatar = await convertAvatarToBase64(avatar, 'https://www.infoq.cn/')
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : 'InfoQ 账号检测失败',
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] InfoQ Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
export { detectInfoQUser, parseInfoQAccount }
@@ -1,4 +1,20 @@
import { convertAvatarToBase64 } from '../utils.js'
import { convertAvatarToBase64 } from '../avatar.js'
export function parseJianshuAccount(json) {
const data = json?.data
if (!data) return null
const rawPlatformUid = data.id ?? data.user_id ?? data.userId ?? data.slug
const platformUid = rawPlatformUid === undefined || rawPlatformUid === null
? undefined
: String(rawPlatformUid).trim() || undefined
return {
platformUid,
username: data.nickname || data.name || '',
avatar: data.avatar || data.avatar_url || '',
}
}
/**
* Jianshu platform detection logic
@@ -7,25 +23,28 @@ import { convertAvatarToBase64 } from '../utils.js'
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()
if (!json?.data) return { loggedIn: false }
const account = parseJianshuAccount(response.body)
if (!account) return { loggedIn: false }
const username = json.data.nickname || ''
let avatar = json.data.avatar || ''
let avatar = account.avatar
if (avatar && avatar.includes('jianshu.io')) {
avatar = await convertAvatarToBase64(avatar, 'https://www.jianshu.com/')
}
return { loggedIn: true, username, avatar }
return { ...account, loggedIn: true, avatar }
} catch (e) {
console.error('[COSE] Jianshu Detection Error:', e)
return { loggedIn: false, error: e.message }
@@ -1,73 +1,73 @@
import { convertAvatarToBase64 } from '../utils.js'
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
/**
* OSChina platform detection logic
* Strategy:
* 1. Check oscid cookie existence as login indicator (MV3 service worker compatible)
* 2. Best-effort: fetch user info via API for username and avatar
* 账号绑定只接受开源中国账号接口返回的稳定 ID。
* 昵称和头像仅用于展示,不能作为发布任务的账号标识。
*/
export async function detectOSChinaUser() {
function parseOSChinaAccount(response) {
const user = response?.result?.userVo && typeof response.result.userVo === 'object'
? response.result.userVo
: undefined
return {
platformUid: normalizeString(user?.id)
|| normalizeString(user?.uid)
|| normalizeString(user?.userId)
|| normalizeString(user?.user_id),
username: normalizeString(user?.name)
|| normalizeString(user?.nickname)
|| normalizeString(user?.userName)
|| '',
avatar: normalizeString(user?.portraitUrl)
|| normalizeString(user?.avatar)
|| normalizeString(user?.avatarUrl)
|| '',
}
}
/**
* 仅在用户当前 Chrome 的开源中国站点上下文中调用账号接口。
* 不读取、复制或上传 Cookie,浏览器只向原站自动携带已有登录态。
*/
async function detectOSChinaUser() {
try {
// Check oscid cookie as login indicator
const oscidCookie = await chrome.cookies.get({ url: 'https://www.oschina.net', name: 'oscid' })
if (!oscidCookie || !oscidCookie.value) {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '开源中国账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://my.oschina.net/',
'https://apiv1.oschina.net/oschinapi/user/myDetails',
{
responseType: 'json',
method: 'GET',
},
)
const account = parseOSChinaAccount(response?.body)
if (
response?.status < 200
|| response?.status >= 300
|| response?.body?.success !== true
|| !account.platformUid
) {
return { loggedIn: false }
}
// Collect all cookies for API request
const cookies = await chrome.cookies.getAll({ domain: '.oschina.net' })
const wwwCookies = await chrome.cookies.getAll({ url: 'https://www.oschina.net' })
const apiCookies = await chrome.cookies.getAll({ url: 'https://apiv1.oschina.net' })
const allCookies = [...cookies, ...wwwCookies, ...apiCookies]
const seen = new Set()
const uniqueCookies = allCookies.filter(c => {
const key = `${c.name}=${c.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
// Best-effort: try to get username and avatar via API
let username = ''
let avatar = ''
let userId = ''
try {
const response = await fetch('https://apiv1.oschina.net/oschinapi/user/myDetails', {
method: 'GET',
headers: {
Accept: 'application/json',
Cookie: cookieStr,
},
})
if (response.ok) {
const data = await response.json()
if (data?.success && data?.result?.userVo) {
username = data.result.userVo.name || ''
avatar = data.result.userVo.portraitUrl || ''
userId = String(data.result.userVo.id || '')
}
}
} catch (e) {
console.log('[COSE] OSChina: API fetch failed, using cookie-only detection')
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '开源中国账号检测失败',
}
if (avatar && (avatar.includes('oschina.net') || avatar.includes('oscimg'))) {
avatar = await convertAvatarToBase64(avatar, 'https://www.oschina.net/')
}
// Store userId for sync URL construction
if (userId) {
try {
await chrome.storage.local.set({ oschina_userId: userId })
} catch (e) {
/* ignore */
}
}
return { loggedIn: true, username, avatar, userId }
} catch (e) {
console.error('[COSE] OSChina Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
export { detectOSChinaUser, parseOSChinaAccount }
@@ -0,0 +1,73 @@
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
function readField(html, fields) {
for (const field of fields) {
const match = html.match(new RegExp(`["']?${field}["']?\\s*[:=]\\s*(?:["']([^"']+)["']|(\\d+))`))
const value = normalizeString(match?.[1] || match?.[2])
if (value) return value
}
return undefined
}
/**
* 企鹅号账号绑定只接受编辑器页面当前账号数据中的 userID。
* 昵称、作者名和头像只用于显示,绝不能作为发布任务的账号标识。
*/
function parseQiehaoAccount(html) {
if (typeof html !== 'string') {
return { platformUid: undefined, username: '', avatar: '' }
}
return {
platformUid: readField(html, ['mediaId', 'userID', 'userId']),
username: readField(html, ['mediaName', 'nickName', 'nickname', 'userName', 'name']) || '',
avatar: readField(html, ['header', 'avatarUrl', 'avatar']) || '',
}
}
/**
* 仅通过用户当前 Chrome 的企鹅号页面上下文读取已登录编辑器。
* 浏览器会自行携带原站登录态;扩展不读取、复制或上传 Cookie、Token、CSRF。
*/
async function detectQiehaoUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '企鹅号账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://om.qq.com/',
'https://om.qq.com/main/creation/article',
{
responseType: 'text',
method: 'GET',
},
)
const account = parseQiehaoAccount(response?.body)
if (
response?.status < 200
|| response?.status >= 300
|| !response?.url?.startsWith('https://om.qq.com/main/creation/article')
|| !account.platformUid
) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '企鹅号账号检测失败',
}
}
}
export { detectQiehaoUser, parseQiehaoAccount }
@@ -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 }
@@ -1,33 +1,75 @@
/**
* Sohu (搜狐号) platform detection logic
* Strategy:
* 1. Check ppinf cookie on mp.sohu.com
* 2. Call account list API for nickname/avatar
*/
export async function detectSohuUser() {
try {
const ppinfCookie = await chrome.cookies.get({ url: 'https://mp.sohu.com', name: 'ppinf' })
if (!ppinfCookie || !ppinfCookie.value) return { loggedIn: false }
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
try {
const response = await fetch('https://mp.sohu.com/mpbp/bp/account/list', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
const data = await response.json()
if (data.success && data.data?.data?.[0]?.accounts?.[0]) {
const account = data.data.data[0].accounts[0]
let avatar = account.avatar || ''
if (avatar.startsWith('//')) avatar = 'https:' + avatar
return { loggedIn: true, username: account.nickName, avatar }
} else {
return { loggedIn: true, username: '', avatar: '' }
}
} catch (e) {
return { loggedIn: true, username: '', avatar: '' }
}
} catch (e) {
return { loggedIn: false }
const normalized = value.trim()
return normalized || undefined
}
function getSohuAccounts(response) {
const data = response?.data
if (Array.isArray(data?.data)) {
return data.data.flatMap(group => (Array.isArray(group?.accounts) ? group.accounts : []))
}
if (Array.isArray(data?.accounts)) return data.accounts
return []
}
/**
* 搜狐号账号识别只接受账号列表返回的稳定账号标识。
* 昵称只用于展示,不能作为发布任务的账号绑定依据。
*/
function parseSohuAccount(response) {
const account = getSohuAccounts(response).find(candidate => {
return Boolean(
normalizeString(candidate?.accountId)
|| normalizeString(candidate?.account_id)
|| normalizeString(candidate?.id),
)
})
const platformUid = normalizeString(account?.accountId)
|| normalizeString(account?.account_id)
|| normalizeString(account?.id)
let avatar = normalizeString(account?.avatar) || normalizeString(account?.avatarUrl) || ''
if (avatar.startsWith('//')) avatar = `https:${avatar}`
return {
platformUid,
username: normalizeString(account?.nickName) || normalizeString(account?.name) || '',
avatar,
}
}
/**
* 在当前 Chrome 的搜狐号页面上下文中读取账号列表。
* 不读取、复制或上传 Cookie;浏览器只用现有站点登录态发送同源请求。
*/
async function detectSohuUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '搜狐号账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://mp.sohu.com/',
'https://mp.sohu.com/mpbp/bp/account/list',
{ responseType: 'json' },
)
const account = parseSohuAccount(response?.body)
if (response?.status < 200 || response?.status >= 300 || response?.body?.success !== true || !account.platformUid) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '搜狐号账号检测失败',
}
}
}
export { detectSohuUser, parseSohuAccount }
@@ -1,55 +1,84 @@
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 getCurrentAccountScope(html) {
const match = html.match(
/"(?:session|currentUser|userInfo|creatorInfo)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,12000}\})/,
)
return match?.[1] || ''
}
/**
* Tencent Cloud platform detection logic
* Strategy:
* 1. Fetch creator page
* 2. Check redirect and parse HTML for nickname/avatar
* 腾讯云的账号绑定只能使用创作中心当前账号块中的稳定标识。
* 页面上任意作者昵称、文章作者 uid 或“创作中心”文案都不能用于绑定账号。
*/
export async function detectTencentCloudUser() {
try {
const response = await fetch('https://cloud.tencent.com/developer/creator', {
method: 'GET',
credentials: 'include',
})
const html = await response.text()
const finalUrl = response.url
function parseTencentCloudAccount(html) {
if (typeof html !== 'string') {
return { platformUid: undefined, username: '', avatar: '' }
}
if (!finalUrl.includes('/creator')) return { loggedIn: false }
if (
html.includes('登录/注册') ||
html.includes('"isLogin":false') ||
html.includes('"login":false')
)
return { loggedIn: false }
const accountScope = getCurrentAccountScope(html)
const readField = field => {
const match = accountScope.match(new RegExp(`"${field}"\\s*:\\s*(?:"([^"]+)"|(\\d+))`))
return normalizeString(match?.[1] || match?.[2])
}
const userInfoMatch =
html.match(/"userInfo"\s*:\s*\{[^}]*"nickname"\s*:\s*"([^"]+)"[^}]*\}/) ||
html.match(/"creatorInfo"\s*:\s*\{[^}]*"nickname"\s*:\s*"([^"]+)"[^}]*\}/) ||
html.match(/"currentUser"\s*:\s*\{[^}]*"nickname"\s*:\s*"([^"]+)"[^}]*\}/)
const creatorNicknameMatch =
html.match(
/class="creator-info[^"]*"[^>]*>[\s\S]*?<[^>]*class="[^"]*name[^"]*"[^>]*>([^<]+)</
) || html.match(/"isCreator"\s*:\s*true[\s\S]*?"nickname"\s*:\s*"([^"]+)"/)
const nicknameMatch = userInfoMatch || creatorNicknameMatch
const avatarMatch =
html.match(/"userInfo"[\s\S]*?"avatarUrl"\s*:\s*"([^"]+)"/) ||
html.match(/"avatar"\s*:\s*"(https?:\/\/[^"]+)"/)
if (nicknameMatch && nicknameMatch[1]) {
let avatar = avatarMatch ? avatarMatch[1] : ''
if (avatar && avatar.includes('qcloudimg.com')) {
avatar = await convertAvatarToBase64(avatar, 'https://cloud.tencent.com/')
}
return { loggedIn: true, username: nicknameMatch[1], avatar }
} else {
if (html.includes('创作中心') || html.includes('我的文章'))
return { loggedIn: true, username: '', avatar: '' }
return { loggedIn: false }
}
} catch (e) {
return { loggedIn: false }
return {
platformUid: readField('loginUid')
|| readField('uin')
|| readField('userId')
|| readField('user_id')
|| readField('uid')
|| readField('creatorId')
|| readField('creator_id')
|| readField('id'),
username: readField('nickname') || readField('name') || '',
avatar: readField('avatarUrl') || readField('avatar') || '',
}
}
/**
* 仅借助用户当前 Chrome 中的腾讯云站点上下文读取创作中心页面。
* 不读取、复制或上传 Cookie,也不接受昵称作为平台账号 UID。
*/
async function detectTencentCloudUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '腾讯云账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://cloud.tencent.com/developer/',
'https://cloud.tencent.com/developer/creator',
{
responseType: 'text',
method: 'GET',
},
)
const account = parseTencentCloudAccount(response?.body)
if (
response?.status < 200
|| response?.status >= 300
|| !response?.url?.includes('/developer/creator')
|| !account.platformUid
) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '腾讯云账号检测失败',
}
}
}
export { detectTencentCloudUser, parseTencentCloudAccount }
@@ -1,51 +1,55 @@
import { convertAvatarToBase64 } from '../utils.js'
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
/**
* 网易号 detection logic
* Strategy:
* 1. Collect cookies via chrome.cookies.getAll (MV3 service worker compatible)
* 2. Fetch user info via mp.163.com/wemedia/navinfo.do with cookies attached manually
* 3. Extract username and avatar from API response
* 网易号账号识别只接受创作者侧返回的 wemediaId。
* 昵称可能变化且并不唯一,不能作为账号绑定或发布交接的身份标识。
*/
export async function detectWangyihaoUser() {
try {
const cookies = await chrome.cookies.getAll({ domain: '.163.com' })
const mpCookies = await chrome.cookies.getAll({ url: 'https://mp.163.com' })
const allCookies = [...cookies, ...mpCookies]
const seen = new Set()
const uniqueCookies = allCookies.filter(c => {
const key = `${c.name}=${c.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
function parseWangyihaoAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
const platformUid = normalizeString(data?.wemediaId)
if (!cookieStr) return { loggedIn: false }
const response = await fetch(`https://mp.163.com/wemedia/navinfo.do?_=${Date.now()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
Cookie: cookieStr,
},
})
if (!response.ok) return { loggedIn: false }
const data = await response.json()
if (data?.code !== 1 || !data?.data?.wemediaId) return { loggedIn: false }
const username = data.data.tname || ''
let avatar = data.data.icon || ''
if (avatar && (avatar.includes('126.net') || avatar.includes('163.com'))) {
avatar = await convertAvatarToBase64(avatar, 'https://mp.163.com/')
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] Wangyihao Detection Error:', e)
return { loggedIn: false, error: e.message }
return {
platformUid,
username: normalizeString(data?.tname) || normalizeString(data?.name) || '',
avatar: normalizeString(data?.icon) || '',
}
}
/**
* 在当前 Chrome 的网易号页面上下文中调用官方账号接口。
* 不读取、复制或上传 Cookie;浏览器仅在站点上下文中自行携带现有登录态。
*/
async function detectWangyihaoUser() {
try {
const fetchFromSiteContext = globalThis.__coseTabContextFetch
if (typeof fetchFromSiteContext !== 'function') {
return { loggedIn: false, error: '网易号账号检测通道不可用' }
}
const response = await fetchFromSiteContext(
'https://mp.163.com/',
`https://mp.163.com/wemedia/navinfo.do?_=${Date.now()}`,
{ responseType: 'json' },
)
const account = parseWangyihaoAccount(response?.body)
if (response?.status < 200 || response?.status >= 300 || response?.body?.code !== 1 || !account.platformUid) {
return { loggedIn: false }
}
return { loggedIn: true, ...account }
} catch (error) {
return {
loggedIn: false,
error: error instanceof Error ? error.message : '网易号账号检测失败',
}
}
}
export { detectWangyihaoUser, parseWangyihaoAccount }
@@ -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).
+51
View File
@@ -0,0 +1,51 @@
(function bridgeAiToEarnInteraction() {
const requestSource = 'aitoearn-interaction-web';
const responseSource = 'aitoearn-interaction-extension';
const allowedMethods = new Set([
'getCapabilities',
'getDouyinAccount',
'listDouyinComments',
'listDouyinReplies',
'replyDouyinComment',
]);
async function handle(method, payload) {
const response = await chrome.runtime.sendMessage({
type: 'AITO_EARN_INTERACTION',
method,
payload,
});
if (!response?.success) {
const error = new Error(response?.error?.message || 'AiToEarn 互动扩展请求失败');
error.code = response?.error?.code || 'INTERACTION_REQUEST_FAILED';
throw error;
}
return response.result;
}
window.addEventListener('message', async (event) => {
if (
event.source !== window
|| event.origin !== window.location.origin
|| event.data?.source !== requestSource
) return;
const { requestId, method, payload } = event.data;
if (!requestId || !allowedMethods.has(method)) return;
try {
const result = await handle(method, payload);
window.postMessage({ source: responseSource, requestId, result }, window.location.origin);
} catch (error) {
window.postMessage({
source: responseSource,
requestId,
error: {
code: error?.code || 'INTERACTION_REQUEST_FAILED',
message: error instanceof Error ? error.message : String(error),
},
}, window.location.origin);
}
});
})();
+72
View File
@@ -0,0 +1,72 @@
(function installAiToEarnInteractionBridge() {
if (window.AIToEarnInteraction) return;
const requestSource = 'aitoearn-interaction-web';
const responseSource = 'aitoearn-interaction-extension';
const pending = new Map();
const defaultTimeoutMs = 60000;
const methodTimeoutMs = Object.freeze({
replyDouyinComment: 120000,
});
window.addEventListener('message', (event) => {
if (
event.source !== window
|| event.origin !== window.location.origin
|| event.data?.source !== responseSource
) return;
const entry = pending.get(event.data.requestId);
if (!entry) return;
pending.delete(event.data.requestId);
if (event.data.error) {
const error = new Error(event.data.error.message || 'AiToEarn 互动扩展请求失败');
error.code = event.data.error.code || 'INTERACTION_REQUEST_FAILED';
entry.reject(error);
return;
}
entry.resolve(event.data.result);
});
function request(method, payload) {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
const timeoutMs = methodTimeoutMs[method] || defaultTimeoutMs;
const timeout = window.setTimeout(() => {
pending.delete(requestId);
const error = new Error('AiToEarn 互动扩展响应超时');
error.code = 'INTERACTION_RESPONSE_TIMEOUT';
reject(error);
}, timeoutMs);
pending.set(requestId, {
resolve(value) {
window.clearTimeout(timeout);
resolve(value);
},
reject(error) {
window.clearTimeout(timeout);
reject(error);
},
});
window.postMessage({ source: requestSource, requestId, method, payload }, window.location.origin);
});
}
Object.defineProperty(window, 'AIToEarnInteraction', {
configurable: false,
enumerable: false,
writable: false,
value: {
getCapabilities: () => request('getCapabilities'),
getDouyinAccount: () => request('getDouyinAccount'),
listDouyinComments: payload => request('listDouyinComments', payload),
listDouyinReplies: payload => request('listDouyinReplies', payload),
replyDouyinComment: payload => request('replyDouyinComment', payload),
},
});
window.dispatchEvent(new CustomEvent('aitoearn:interaction-ready'));
})();
+238
View File
@@ -0,0 +1,238 @@
import {
doesDouyinAccountMatch,
executeDouyinCommentRequest,
executeDouyinReplyRequest,
executeDouyinSessionRequest,
InteractionValidationError,
normalizeDouyinRequest,
} from './douyin.js';
import { createInteractionCapabilities } from './capabilities.js';
const MESSAGE_TYPE = 'AITO_EARN_INTERACTION';
const ALLOWED_APP_ORIGINS = new Set([
'http://localhost:6061',
'http://127.0.0.1:6061',
'https://wx.frp.it1024.cc',
]);
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
function createError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function isAllowedSender(sender) {
const senderUrl = sender?.url || sender?.tab?.url;
if (!senderUrl) return false;
try {
return ALLOWED_APP_ORIGINS.has(new URL(senderUrl).origin);
} catch {
return false;
}
}
async function waitForTabReady(tabId) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const tab = await chrome.tabs.get(tabId);
if (tab.status === 'complete') return tab;
await sleep(250);
}
throw createError('DOUYIN_PAGE_TIMEOUT', '抖音页面加载超时');
}
async function acquireDouyinTab(workId) {
const workUrl = `https://www.douyin.com/video/${workId}`;
const tabs = await chrome.tabs.query({ url: ['https://www.douyin.com/*'] });
let tab = tabs.find(candidate => candidate.id && candidate.url?.startsWith(workUrl));
if (!tab) tab = tabs.find(candidate => candidate.id && !candidate.discarded);
const created = !tab;
if (!tab) tab = await chrome.tabs.create({ url: workUrl, active: false });
if (!tab.id) throw createError('DOUYIN_TAB_UNAVAILABLE', '无法创建或复用抖音标签页');
await waitForTabReady(tab.id);
return { tabId: tab.id, created };
}
async function acquireDouyinCreatorTab(allowCreate) {
const tabs = await chrome.tabs.query({ url: ['https://creator.douyin.com/*'] });
let tab = tabs.find(candidate => candidate.id && !candidate.discarded);
const created = !tab;
if (!tab && !allowCreate) {
throw createError(
'DOUYIN_CREATOR_TAB_REQUIRED',
'请先在当前浏览器打开并登录抖音创作者中心,再刷新互动管理',
);
}
if (!tab) tab = await chrome.tabs.create({ url: 'https://creator.douyin.com/', active: false });
if (!tab.id) throw createError('DOUYIN_CREATOR_TAB_UNAVAILABLE', '无法创建或复用抖音创作者中心标签页');
await waitForTabReady(tab.id);
return { tabId: tab.id, created };
}
async function readDouyinSession(includeCsrf = false, allowCreate = false) {
const tab = await acquireDouyinCreatorTab(allowCreate);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinSessionRequest,
args: [{ includeCsrf }],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_ACCOUNT_REQUEST_FAILED';
const message = response?.error?.message || '抖音账号读取失败';
if (code === 'DOUYIN_LOGIN_REQUIRED') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return response.result;
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
async function listDouyinComments(method, payload) {
const request = normalizeDouyinRequest(method, payload);
const tab = await acquireDouyinTab(request.workId);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinCommentRequest,
args: [{
...request,
kind: method === 'listDouyinReplies' ? 'replies' : 'comments',
}],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_REQUEST_FAILED';
const message = response?.error?.message || '抖音评论读取失败';
if (code === 'DOUYIN_LOGIN_REQUIRED') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return {
...response.result,
reusedTab: !tab.created,
};
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
async function replyDouyinComment(payload) {
const request = normalizeDouyinRequest('replyDouyinComment', payload);
const session = await readDouyinSession(true, true);
if (!doesDouyinAccountMatch(request.expectedAccountUid, session.account)) {
throw createError(
'DOUYIN_ACCOUNT_MISMATCH',
`当前浏览器登录的是“${session.account.nickname || session.account.uid}”,与所选 AiToEarn 账号不一致`,
);
}
const tab = await acquireDouyinTab(request.workId);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinReplyRequest,
args: [{ ...request, csrfToken: session.csrfToken }],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_REPLY_REQUEST_FAILED';
const message = response?.error?.message || '抖音评论回复失败';
if (code === 'DOUYIN_LOGIN_REQUIRED' || code === 'DOUYIN_RISK_CONTROL') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return {
...response.result,
account: session.account,
reusedTab: !tab.created,
};
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
function getCapabilities() {
return createInteractionCapabilities(chrome.runtime.getManifest().version);
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request?.type !== MESSAGE_TYPE) return false;
if (!isAllowedSender(sender)) {
sendResponse({
success: false,
error: { code: 'INTERACTION_ORIGIN_DENIED', message: '当前页面无权调用互动扩展' },
});
return false;
}
const method = request.method;
if (![
'getCapabilities',
'getDouyinAccount',
'listDouyinComments',
'listDouyinReplies',
'replyDouyinComment',
].includes(method)) {
sendResponse({
success: false,
error: { code: 'INTERACTION_METHOD_DENIED', message: '不支持的互动扩展方法' },
});
return false;
}
Promise.resolve(
method === 'getCapabilities'
? getCapabilities()
: method === 'getDouyinAccount'
? readDouyinSession(false, false).then(session => session.account)
: method === 'replyDouyinComment'
? replyDouyinComment(request.payload)
: listDouyinComments(method, request.payload),
)
.then(result => sendResponse({ success: true, result }))
.catch((error) => {
sendResponse({
success: false,
error: {
code: error?.code || (error instanceof InteractionValidationError
? 'INVALID_INTERACTION_PAYLOAD'
: 'INTERACTION_REQUEST_FAILED'),
message: error instanceof Error ? error.message : String(error),
},
});
});
return true;
});
+16
View File
@@ -0,0 +1,16 @@
export function createInteractionCapabilities(version) {
return {
version,
source: 'aitoearn-interaction',
platforms: {
douyin: {
comments: {
list: true,
replies: true,
create: false,
manualReply: true,
},
},
},
};
}
+430
View File
@@ -0,0 +1,430 @@
const ID_PATTERN = /^\d{1,32}$/;
const CURSOR_PATTERN = /^\d{1,16}$/;
const DEFAULT_COUNT = 20;
const MAX_COUNT = 20;
const MAX_REPLY_LENGTH = 500;
export class InteractionValidationError extends Error {
constructor(code, message) {
super(message);
this.name = 'InteractionValidationError';
this.code = code;
}
}
function normalizeId(value, fieldName) {
const id = typeof value === 'number' ? String(value) : String(value || '').trim();
if (!ID_PATTERN.test(id)) {
throw new InteractionValidationError('INVALID_DOUYIN_ID', `${fieldName} 必须是纯数字 ID`);
}
return id;
}
function normalizeCursor(value) {
if (value === undefined || value === null || value === '') return '0';
const cursor = typeof value === 'number' ? String(value) : String(value).trim();
if (!CURSOR_PATTERN.test(cursor)) {
throw new InteractionValidationError('INVALID_DOUYIN_CURSOR', '分页游标必须是非负整数');
}
return cursor;
}
function normalizeCount(value) {
if (value === undefined || value === null || value === '') return DEFAULT_COUNT;
const count = Number(value);
if (!Number.isInteger(count) || count < 1) {
throw new InteractionValidationError('INVALID_DOUYIN_COUNT', '每页数量必须是正整数');
}
return Math.min(count, MAX_COUNT);
}
function normalizeReplyContent(value) {
const content = typeof value === 'string' ? value.trim() : '';
if (!content) {
throw new InteractionValidationError('EMPTY_DOUYIN_REPLY', '回复内容不能为空');
}
if (content.length > MAX_REPLY_LENGTH) {
throw new InteractionValidationError(
'DOUYIN_REPLY_TOO_LONG',
`回复内容不能超过 ${MAX_REPLY_LENGTH} 个字符`,
);
}
return content;
}
function normalizeExpectedAccountUid(value) {
const uid = typeof value === 'string' ? value.trim() : '';
if (!uid || uid.length > 256 || /[\u0000-\u001f\u007f]/.test(uid)) {
throw new InteractionValidationError('INVALID_DOUYIN_ACCOUNT_UID', '缺少有效的抖音账号 UID');
}
return uid;
}
export function doesDouyinAccountMatch(expectedAccountUid, account) {
const expected = typeof expectedAccountUid === 'string' ? expectedAccountUid.trim() : '';
if (!expected || !account || typeof account !== 'object') return false;
return [account.uid, account.numericUid, account.uniqueId]
.some(value => typeof value === 'string' && value === expected);
}
export async function executeDouyinSessionRequest(options = {}) {
function getAvatarUrl(user) {
return [
...(user?.avatar_thumb?.url_list || []),
...(user?.avatar_medium?.url_list || []),
].find(url => typeof url === 'string' && url.startsWith('https://')) || '';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const userResponse = await fetch('/web/api/media/user/info/', {
credentials: 'include',
headers: { accept: 'application/json, text/plain, */*' },
signal: controller.signal,
});
if (!userResponse.ok) {
return {
ok: false,
error: {
code: userResponse.status === 401 || userResponse.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_ACCOUNT_HTTP_ERROR',
message: `抖音账号接口返回 HTTP ${userResponse.status}`,
},
};
}
const data = await userResponse.json();
const user = data?.user || data?.user_info;
if (Number(data?.status_code) !== 0 || !user) {
return {
ok: false,
error: {
code: 'DOUYIN_LOGIN_REQUIRED',
message: data?.status_msg || '当前浏览器尚未登录抖音创作者中心',
},
};
}
const account = {
uid: String(user.sec_uid || user.uid || ''),
numericUid: String(user.uid || ''),
uniqueId: String(user.unique_id || ''),
nickname: typeof user.nickname === 'string' ? user.nickname : '',
avatarUrl: getAvatarUrl(user),
};
if (!account.uid) {
return {
ok: false,
error: {
code: 'DOUYIN_ACCOUNT_ID_MISSING',
message: '抖音账号接口未返回可核对的账号 UID',
},
};
}
if (!options.includeCsrf) {
return { ok: true, result: { account } };
}
const csrfResponse = await fetch('/web/api/media/anchor/search', {
method: 'HEAD',
credentials: 'include',
headers: {
accept: '*/*',
'X-Secsdk-Csrf-Request': '1',
'X-Secsdk-Csrf-Version': '1.2.22',
},
signal: controller.signal,
});
const csrfHeader = csrfResponse.headers.get('x-ware-csrf-token') || '';
const csrfParts = csrfHeader.split(',').map(value => value.trim()).filter(Boolean);
const csrfToken = csrfParts[1] || csrfParts[0] || '';
if (!csrfResponse.ok || !csrfToken) {
return {
ok: false,
error: {
code: 'DOUYIN_CSRF_TOKEN_FAILED',
message: '无法获取抖音回复所需的安全令牌,请刷新创作者中心后重试',
},
};
}
return { ok: true, result: { account, csrfToken } };
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError'
? 'DOUYIN_ACCOUNT_REQUEST_TIMEOUT'
: 'DOUYIN_ACCOUNT_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音账号读取超时,请刷新创作者中心后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
export async function executeDouyinReplyRequest(request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const body = new FormData();
body.set('aweme_id', request.workId);
body.set('reply_id', request.commentId);
body.set('text', request.content);
body.set('one_level_comment_rank', '1');
const response = await fetch('/aweme/v1/web/comment/publish/?aid=6383', {
method: 'POST',
credentials: 'include',
headers: {
accept: 'application/json, text/plain, */*',
'x-secsdk-csrf-token': request.csrfToken,
},
body,
signal: controller.signal,
});
if (!response.ok) {
return {
ok: false,
error: {
code: response.status === 401 || response.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_REPLY_HTTP_ERROR',
message: `抖音回复接口返回 HTTP ${response.status}`,
},
};
}
const data = await response.json();
if (Number(data?.status_code) !== 0) {
const message = data?.status_msg || `抖音回复接口返回状态 ${data?.status_code}`;
return {
ok: false,
error: {
code: /验证|频繁|风险|安全/.test(message)
? 'DOUYIN_RISK_CONTROL'
: /登录/.test(message)
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_REPLY_API_ERROR',
message,
},
};
}
return {
ok: true,
result: {
platform: 'douyin',
workId: request.workId,
parentCommentId: request.commentId,
platformCommentId: String(data?.comment?.cid || data?.cid || ''),
success: true,
source: 'douyin-web',
},
};
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError' ? 'DOUYIN_REPLY_TIMEOUT' : 'DOUYIN_REPLY_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音回复请求超时,请确认页面可正常访问后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
export function normalizeDouyinRequest(method, payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new InteractionValidationError('INVALID_INTERACTION_PAYLOAD', '互动请求参数格式不正确');
}
const workId = normalizeId(payload.workId, '作品 ID');
if (method === 'replyDouyinComment') {
return {
workId,
commentId: normalizeId(payload.commentId, '评论 ID'),
content: normalizeReplyContent(payload.content),
expectedAccountUid: normalizeExpectedAccountUid(payload.expectedAccountUid),
};
}
const normalized = {
workId,
cursor: normalizeCursor(payload.cursor),
count: normalizeCount(payload.count),
};
if (method === 'listDouyinReplies') {
normalized.commentId = normalizeId(payload.commentId, '评论 ID');
}
return normalized;
}
export async function executeDouyinCommentRequest(request) {
function getAvatarUrl(user) {
const candidates = [
...(user?.avatar_thumb?.url_list || []),
...(user?.avatar_medium?.url_list || []),
];
return candidates.find(url => typeof url === 'string' && url.startsWith('https://')) || '';
}
function toFiniteNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function normalizeComment(comment, parentCommentId) {
const id = String(comment?.cid || '');
const nestedReplies = Array.isArray(comment?.reply_comment)
? comment.reply_comment.map(reply => normalizeComment(reply, id)).filter(reply => reply.id)
: [];
const createdAtSeconds = toFiniteNumber(comment?.create_time);
return {
id,
workId: String(comment?.aweme_id || request.workId),
parentCommentId: parentCommentId || undefined,
rootCommentId: String(parentCommentId || comment?.root_comment_id || id || ''),
text: typeof comment?.text === 'string' ? comment.text : '',
createdAt: createdAtSeconds > 0 ? new Date(createdAtSeconds * 1000).toISOString() : undefined,
likeCount: toFiniteNumber(comment?.digg_count),
replyCount: toFiniteNumber(
parentCommentId
? comment?.reply_comment_total
: comment?.reply_comment_total ?? comment?.comment_reply_total,
nestedReplies.length,
),
ipLabel: typeof comment?.ip_label === 'string' ? comment.ip_label : '',
isAuthor: comment?.label_text === '作者' || Number(comment?.label_type) === 1,
author: {
name: typeof comment?.user?.nickname === 'string' ? comment.user.nickname : '',
uid: String(comment?.user?.uid || ''),
secUid: String(comment?.user?.sec_uid || ''),
avatarUrl: getAvatarUrl(comment?.user),
},
replies: nestedReplies,
};
}
function getBrowserVersion() {
const match = navigator.userAgent.match(/(?:Chrome|Edg)\/(\d+(?:\.\d+){0,3})/);
return match?.[1] || '';
}
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const commonParams = {
device_platform: 'webapp',
aid: '6383',
channel: 'channel_pc_web',
item_type: '0',
cut_version: '1',
update_version_code: '170400',
pc_client_type: '1',
pc_libra_divert: 'Windows',
support_h265: '1',
support_dash: '1',
cpu_core_num: String(navigator.hardwareConcurrency || 8),
version_code: '170400',
version_name: '17.4.0',
cookie_enabled: String(navigator.cookieEnabled),
screen_width: String(screen.width || 1920),
screen_height: String(screen.height || 1080),
browser_language: navigator.language || 'zh-CN',
browser_platform: navigator.platform || 'Win32',
browser_name: navigator.userAgent.includes('Edg/') ? 'Edge' : 'Chrome',
browser_version: getBrowserVersion(),
browser_online: String(navigator.onLine),
engine_name: 'Blink',
os_name: 'Windows',
device_memory: String(navigator.deviceMemory || 8),
platform: 'PC',
downlink: String(connection?.downlink || 10),
effective_type: connection?.effectiveType || '4g',
round_trip_time: String(connection?.rtt || 50),
};
const isReplies = request.kind === 'replies';
const params = new URLSearchParams({
...commonParams,
cursor: request.cursor,
count: String(request.count),
...(isReplies
? { item_id: request.workId, comment_id: request.commentId }
: { aweme_id: request.workId }),
});
const endpoint = isReplies
? '/aweme/v1/web/comment/list/reply/'
: '/aweme/v1/web/comment/list/';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const response = await fetch(`${endpoint}?${params}`, {
credentials: 'include',
headers: { accept: 'application/json, text/plain, */*' },
signal: controller.signal,
});
if (!response.ok) {
return {
ok: false,
error: {
code: response.status === 401 || response.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_HTTP_ERROR',
message: `抖音评论接口返回 HTTP ${response.status}`,
},
};
}
const data = await response.json();
if (Number(data?.status_code) !== 0) {
return {
ok: false,
error: {
code: 'DOUYIN_API_ERROR',
message: data?.status_msg || `抖音评论接口返回状态 ${data?.status_code}`,
},
};
}
return {
ok: true,
result: {
platform: 'douyin',
workId: request.workId,
parentCommentId: isReplies ? request.commentId : undefined,
items: (Array.isArray(data.comments) ? data.comments : [])
.map(comment => normalizeComment(comment, isReplies ? request.commentId : undefined))
.filter(comment => comment.id),
cursor: String(data.cursor ?? ''),
hasMore: Boolean(data.has_more),
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
source: 'douyin-web',
},
};
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError' ? 'DOUYIN_REQUEST_TIMEOUT' : 'DOUYIN_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音评论请求超时,请确认页面可以正常访问后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
+9 -5
View File
@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.2.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测多平台文章分发",
"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/*",
@@ -83,7 +87,7 @@
"http://127.0.0.1:6061/*",
"https://wx.frp.it1024.cc/*"
],
"js": ["distribution-page-bridge.js"],
"js": ["distribution-page-bridge.js", "interaction-page-bridge.js"],
"run_at": "document_start",
"world": "MAIN"
},
@@ -93,7 +97,7 @@
"http://127.0.0.1:6061/*",
"https://wx.frp.it1024.cc/*"
],
"js": ["distribution-bridge.js"],
"js": ["distribution-bridge.js", "interaction-bridge.js"],
"run_at": "document_start"
},
{
+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));
+10
View File
@@ -0,0 +1,10 @@
{
"name": "aitoearn-chrome-extension",
"private": true,
"type": "module",
"scripts": {
"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"
}
}
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
+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));
+93 -3
View File
@@ -17,6 +17,8 @@ $requiredPaths = @(
'content.js',
'distribution-bridge.js',
'distribution-page-bridge.js',
'interaction-bridge.js',
'interaction-page-bridge.js',
'popup.html',
'popup.css',
'popup.js',
@@ -24,7 +26,8 @@ $requiredPaths = @(
'options.css',
'options.js',
'icons',
'distribution'
'distribution',
'interaction'
)
foreach ($relativePath in $requiredPaths) {
@@ -47,6 +50,21 @@ foreach ($javascriptFile in $javascriptFiles) {
}
}
& node (Join-Path $PSScriptRoot 'test-interaction.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Interaction bridge validation failed'
}
& node (Join-Path $PSScriptRoot 'test-distribution.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Distribution bridge validation failed'
}
& 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"
@@ -76,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) {
@@ -89,7 +167,19 @@ if (-not (Test-Path -LiteralPath (Join-Path $verificationRoot 'manifest.json')))
throw 'Packaged archive does not contain manifest.json at its root'
}
$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$archiveStream = $null
try {
$archiveStream = [System.IO.File]::OpenRead($archivePath)
$archiveHashBytes = $sha256.ComputeHash($archiveStream)
}
finally {
if ($null -ne $archiveStream) {
$archiveStream.Dispose()
}
$sha256.Dispose()
}
$archiveHash = -join ($archiveHashBytes | ForEach-Object { $_.ToString('x2') })
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Remove-Item -LiteralPath $verificationRoot -Recurse -Force
Remove-Item -LiteralPath $stageRoot -Recurse -Force
+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
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { createInteractionCapabilities } from '../interaction/capabilities.js';
import {
doesDouyinAccountMatch,
executeDouyinReplyRequest,
executeDouyinSessionRequest,
InteractionValidationError,
normalizeDouyinRequest,
} from '../interaction/douyin.js';
assert.deepEqual(
createInteractionCapabilities('1.4.0').platforms.douyin.comments,
{ list: true, replies: true, create: false, manualReply: true },
);
assert.deepEqual(
normalizeDouyinRequest('listDouyinComments', { workId: '7570305000069549352' }),
{ workId: '7570305000069549352', cursor: '0', count: 20 },
);
assert.deepEqual(
normalizeDouyinRequest('replyDouyinComment', {
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: ' 感谢你的反馈 ',
expectedAccountUid: 'MS4wLjABAAAA_test-account',
}),
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '感谢你的反馈',
expectedAccountUid: 'MS4wLjABAAAA_test-account',
},
);
assert.equal(doesDouyinAccountMatch('sec-uid', {
uid: 'sec-uid',
numericUid: '123',
uniqueId: 'account-name',
}), true);
assert.equal(doesDouyinAccountMatch('another-account', {
uid: 'sec-uid',
numericUid: '123',
uniqueId: 'account-name',
}), false);
assert.deepEqual(
normalizeDouyinRequest('listDouyinReplies', {
workId: '7570305000069549352',
commentId: '7570539465648243462',
cursor: 20,
count: 999,
}),
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
cursor: '20',
count: 20,
},
);
for (const payload of [
{ workId: 'https://example.com/' },
{ workId: '7570305000069549352', cursor: '-1' },
{ workId: '7570305000069549352', count: 0 },
]) {
assert.throws(
() => normalizeDouyinRequest('listDouyinComments', payload),
error => error instanceof InteractionValidationError,
);
}
for (const payload of [
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '',
expectedAccountUid: 'sec-uid',
},
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: 'x'.repeat(501),
expectedAccountUid: 'sec-uid',
},
{
workId: '7570305000069549352',
commentId: 'not-an-id',
content: 'reply',
expectedAccountUid: 'sec-uid',
},
]) {
assert.throws(
() => normalizeDouyinRequest('replyDouyinComment', payload),
error => error instanceof InteractionValidationError,
);
}
const originalFetch = globalThis.fetch;
try {
const sessionCalls = [];
globalThis.fetch = async (url, options = {}) => {
sessionCalls.push({ url, options });
if (url === '/web/api/media/user/info/') {
return {
ok: true,
status: 200,
json: async () => ({
status_code: 0,
user: {
sec_uid: 'sec-uid',
uid: '123456',
unique_id: 'aitoearn-test',
nickname: 'AiToEarn 测试账号',
avatar_thumb: { url_list: ['https://example.com/avatar.png'] },
},
}),
};
}
if (url === '/web/api/media/anchor/search') {
return {
ok: true,
status: 200,
headers: new Headers({ 'x-ware-csrf-token': 'challenge,csrf-token-value' }),
};
}
throw new Error(`Unexpected session URL: ${url}`);
};
assert.deepEqual(await executeDouyinSessionRequest({ includeCsrf: true }), {
ok: true,
result: {
account: {
uid: 'sec-uid',
numericUid: '123456',
uniqueId: 'aitoearn-test',
nickname: 'AiToEarn 测试账号',
avatarUrl: 'https://example.com/avatar.png',
},
csrfToken: 'csrf-token-value',
},
});
assert.equal(sessionCalls.length, 2);
assert.equal(sessionCalls[1].options.method, 'HEAD');
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Request'], '1');
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Version'], '1.2.22');
const replyCalls = [];
globalThis.fetch = async (url, options = {}) => {
replyCalls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({
status_code: 0,
comment: { cid: 'new-comment-id' },
}),
};
};
assert.deepEqual(await executeDouyinReplyRequest({
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '感谢你的反馈',
csrfToken: 'csrf-token-value',
}), {
ok: true,
result: {
platform: 'douyin',
workId: '7570305000069549352',
parentCommentId: '7570539465648243462',
platformCommentId: 'new-comment-id',
success: true,
source: 'douyin-web',
},
});
assert.equal(replyCalls.length, 1);
assert.equal(replyCalls[0].url, '/aweme/v1/web/comment/publish/?aid=6383');
assert.equal(replyCalls[0].options.method, 'POST');
assert.equal(replyCalls[0].options.headers['x-secsdk-csrf-token'], 'csrf-token-value');
assert.equal(replyCalls[0].options.body.get('aweme_id'), '7570305000069549352');
assert.equal(replyCalls[0].options.body.get('reply_id'), '7570539465648243462');
assert.equal(replyCalls[0].options.body.get('text'), '感谢你的反馈');
assert.equal(replyCalls[0].options.body.get('one_level_comment_rank'), '1');
let failedReplyCallCount = 0;
globalThis.fetch = async () => {
failedReplyCallCount += 1;
return {
ok: false,
status: 500,
json: async () => ({}),
};
};
const failedReply = await executeDouyinReplyRequest({
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '仅发送一次',
csrfToken: 'csrf-token-value',
});
assert.equal(failedReply.ok, false);
assert.equal(failedReply.error.code, 'DOUYIN_REPLY_HTTP_ERROR');
assert.equal(failedReplyCallCount, 1);
} finally {
globalThis.fetch = originalFetch;
}
console.log('Interaction bridge validation passed.');
+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
})