Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef64467f6b | |||
| c2d2a6c749 | |||
| 537df968a0 | |||
| dd9e628ac2 | |||
| 679e949703 | |||
| 984d646e1d | |||
| 52e8f7f4a0 | |||
| da8ad24f14 | |||
| 6561378bc1 | |||
| be650f6fab | |||
| 8a8dcb0023 | |||
| 874455e5f2 | |||
| ae62ff02b6 | |||
| 5821c5711b | |||
| 6aea2f6810 | |||
| 1a94449988 | |||
| 4f454f0ada | |||
| 026b366f50 | |||
| 61cdcc1afb | |||
| e2244a20bf | |||
| 8612e86ba3 | |||
| 77d44bb52f | |||
| 7988fdab53 | |||
| c98c2d3864 | |||
| f6f2c03790 | |||
| 6f55d2c809 | |||
| 1e48681c86 | |||
| fd192bf9ba |
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.zip binary
|
||||
@@ -8,13 +8,19 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
|
||||
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动桥接,不冒充旧官方扩展的完整能力。
|
||||
- 复用浏览器现有登录状态检测内容平台账号。
|
||||
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
|
||||
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
|
||||
- 扩展弹窗从 COSE 适配器动态读取已完成受控填充验证的平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
|
||||
|
||||
## Browser Provider
|
||||
|
||||
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前首个完整接入平台是掘金:扩展读取当前掘金 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充 Markdown 编辑器。
|
||||
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`。
|
||||
|
||||
任务会复用已有编辑器标签页。标签页仍位于新建编辑器、已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。`/editor/drafts/:id` 只记为草稿已保存,只有 `/post/:id` 才记为已发布;无法确定时进入“发布结果待确认”,不会伪报成功。
|
||||
扩展支持服务端随发布任务下发经过签名安装、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 尚未在真实创作者账号上验收,因此未知页面会进入“发布结果待确认”,不会伪报成功。
|
||||
|
||||
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
|
||||
|
||||
@@ -52,7 +58,7 @@ AiToEarn 自行维护网页桥接、载荷协议、产品界面、打包和发
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\package.ps1
|
||||
```
|
||||
|
||||
脚本会校验 `manifest.json`、全部 JavaScript 语法和必需文件,再输出:
|
||||
脚本会校验 `manifest.json`、全部 JavaScript 语法、必需文件、互动桥接、分发桥接和 Browser Agent Provider 快照执行链,再输出:
|
||||
|
||||
```text
|
||||
dist/aitoearn-extension-v<version>.zip
|
||||
|
||||
+12
-2
@@ -1,5 +1,7 @@
|
||||
import './distribution/cose/background.js';
|
||||
import './interaction/background.js';
|
||||
import './distribution/browser-agent/index.js';
|
||||
import { AUTO_FILL_PLATFORM_IDS } from './distribution/cose/core/platforms/index.js';
|
||||
|
||||
const PREPARE_BATCH_MESSAGE = 'PREPARE_PLATFORM_BATCH';
|
||||
const CAPTURE_URL_MESSAGE = 'CAPTURE_URL';
|
||||
@@ -211,11 +213,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
|
||||
if (request.type !== PREPARE_BATCH_MESSAGE) return false;
|
||||
|
||||
const platformIds = Array.isArray(request.platformIds)
|
||||
const requestedPlatformIds = Array.isArray(request.platformIds)
|
||||
? request.platformIds.filter(platformId => typeof platformId === 'string' && platformId.trim())
|
||||
: [];
|
||||
const platformIds = [...new Set(requestedPlatformIds)]
|
||||
.filter(platformId => AUTO_FILL_PLATFORM_IDS.has(platformId));
|
||||
|
||||
if (platformIds.length === 0 || !request.content || typeof request.content !== 'object') {
|
||||
sendResponse({ success: false, error: '缺少目标平台或分发内容' });
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: requestedPlatformIds.length > 0
|
||||
? '所选平台暂未开放受控自动填充,请使用对应平台的复制发布稿。'
|
||||
: '缺少目标平台或分发内容',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
'syncToPlatform',
|
||||
'openPlatformTask',
|
||||
'inspectPlatformTask',
|
||||
'pairBrowserAgent',
|
||||
'getBrowserAgentStatus',
|
||||
'pollBrowserAgentNow',
|
||||
'disconnectBrowserAgent',
|
||||
]);
|
||||
|
||||
async function sendRuntimeMessage(message) {
|
||||
@@ -88,6 +92,17 @@
|
||||
expectedPlatformUid: payload.expectedPlatformUid,
|
||||
});
|
||||
}
|
||||
case 'pairBrowserAgent':
|
||||
return sendRuntimeMessage({
|
||||
type: 'PAIR_BROWSER_AGENT',
|
||||
payload,
|
||||
});
|
||||
case 'getBrowserAgentStatus':
|
||||
return sendRuntimeMessage({ type: 'GET_BROWSER_AGENT_STATUS' });
|
||||
case 'pollBrowserAgentNow':
|
||||
return sendRuntimeMessage({ type: 'POLL_BROWSER_AGENT_NOW' });
|
||||
case 'disconnectBrowserAgent':
|
||||
return sendRuntimeMessage({ type: 'DISCONNECT_BROWSER_AGENT' });
|
||||
default:
|
||||
throw new Error(`不支持的分发方法: ${method}`);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
tabId,
|
||||
expectedPlatformUid,
|
||||
}),
|
||||
pairBrowserAgent: payload => request('pairBrowserAgent', payload),
|
||||
getBrowserAgentStatus: () => request('getBrowserAgentStatus'),
|
||||
pollBrowserAgentNow: () => request('pollBrowserAgentNow'),
|
||||
disconnectBrowserAgent: () => request('disconnectBrowserAgent'),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from '../cose/core/platforms/toutiao.js'
|
||||
import { AUTO_FILL_PLATFORM_IDS } from '../cose/core/platforms/index.js'
|
||||
import {
|
||||
getDeclarativeProviderAction,
|
||||
validateBrowserProviderSnapshot,
|
||||
} from '../cose/core/declarative-browser-provider.js'
|
||||
|
||||
const ALLOWED_API_ORIGINS = new Set([
|
||||
'https://wxapi.frp.it1024.cc',
|
||||
'http://localhost:3002',
|
||||
])
|
||||
|
||||
const BROWSER_AGENT_STORAGE_KEY = 'aitoearn_browser_agent_v1'
|
||||
const BROWSER_AGENT_ALARM_NAME = 'aitoearn-browser-agent-poll'
|
||||
const BROWSER_AGENT_PLATFORM_IDS = [...AUTO_FILL_PLATFORM_IDS]
|
||||
.filter(platformId => platformId !== 'wechat')
|
||||
|
||||
const PLATFORM_TAB_PATTERNS = {
|
||||
csdn: ['https://editor.csdn.net/md/*'],
|
||||
juejin: ['https://juejin.cn/*'],
|
||||
jianshu: ['https://www.jianshu.com/*'],
|
||||
zhihu: ['https://zhuanlan.zhihu.com/*', 'https://www.zhihu.com/*'],
|
||||
toutiao: ['https://mp.toutiao.com/*'],
|
||||
baijiahao: ['https://baijiahao.baidu.com/*'],
|
||||
wangyi: ['https://mp.163.com/*'],
|
||||
sohu: ['https://mp.sohu.com/*'],
|
||||
infoq: ['https://xie.infoq.cn/*'],
|
||||
tencentcloud: ['https://cloud.tencent.com/developer/*'],
|
||||
aliyun: ['https://developer.aliyun.com/*'],
|
||||
segmentfault: ['https://segmentfault.com/*'],
|
||||
'51cto': ['https://blog.51cto.com/*'],
|
||||
oschina: ['https://my.oschina.net/*'],
|
||||
qiehao: ['https://om.qq.com/*'],
|
||||
}
|
||||
|
||||
const PLATFORM_ACTIONS = {
|
||||
csdn: {
|
||||
draft: { selector: 'button.btn.btn-save', text: '保存草稿' },
|
||||
publish: { selector: 'button.btn.btn-publish', text: '发布文章' },
|
||||
},
|
||||
tencentcloud: {
|
||||
draft: { selector: 'button.cdc-btn.cdc-btn--hole', text: '存草稿' },
|
||||
publish: { selector: 'button.cdc-btn.cdc-btn--primary', text: '去发布' },
|
||||
},
|
||||
aliyun: {
|
||||
draft: { selector: 'button.public-button.gray-button', text: '存为草稿' },
|
||||
publish: { selector: 'button.public-button.orange-button', text: '发布文章' },
|
||||
},
|
||||
qiehao: {
|
||||
draft: { selector: 'button.omui-button.omui-button--background-grey', text: '存草稿' },
|
||||
publish: { selector: 'button.omui-button.omui-button--primary', text: '发布' },
|
||||
},
|
||||
}
|
||||
|
||||
let pollingPromise = null
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
const rawValue = typeof value === 'string' && value.trim()
|
||||
? value.trim()
|
||||
: 'https://wxapi.frp.it1024.cc'
|
||||
const url = new URL(rawValue)
|
||||
if (url.pathname !== '/' || url.search || url.hash || !ALLOWED_API_ORIGINS.has(url.origin)) {
|
||||
throw new Error('浏览器 Agent 只允许连接已配置的 AiToEarn API 地址')
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function getPlatformAction(platformId, publishMode, providerConfig) {
|
||||
if (providerConfig !== undefined) {
|
||||
return getDeclarativeProviderAction(providerConfig, platformId, publishMode)
|
||||
}
|
||||
return PLATFORM_ACTIONS[platformId]?.[publishMode] || null
|
||||
}
|
||||
|
||||
function getTaskExecutionPolicy(platformId, publishMode, openedStatus, providerConfig) {
|
||||
if (openedStatus === 'published') return 'report_published'
|
||||
|
||||
const action = getPlatformAction(platformId, publishMode, providerConfig)
|
||||
if (openedStatus === 'draft_saved' && (publishMode === 'draft' || !action)) {
|
||||
return 'report_draft_saved'
|
||||
}
|
||||
if (action) return 'perform_action'
|
||||
if (openedStatus === 'publication_uncertain') return 'report_uncertain'
|
||||
return 'report_filled'
|
||||
}
|
||||
|
||||
function getExtensionVersion() {
|
||||
return chrome.runtime.getManifest().version
|
||||
}
|
||||
|
||||
function getMessage(error, fallback) {
|
||||
if (error instanceof Error && error.message) return error.message.slice(0, 1000)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function unwrapApiPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') return payload
|
||||
if (typeof payload.code === 'number' && payload.code !== 0) {
|
||||
throw new Error(typeof payload.message === 'string' ? payload.message : '浏览器 Agent 请求失败')
|
||||
}
|
||||
return payload.data === undefined ? payload : payload.data
|
||||
}
|
||||
|
||||
async function requestBrowserAgent(agent, path, init = {}) {
|
||||
const headers = new Headers(init.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
if (agent?.token) headers.set('x-aitoearn-browser-agent', agent.token)
|
||||
if (init.body !== undefined) headers.set('Content-Type', 'application/json')
|
||||
|
||||
const response = await fetch(agent.apiBaseUrl + path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
let payload
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new Error('AiToEarn API 返回了无效响应')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = payload && typeof payload === 'object' && typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '浏览器 Agent 请求失败:' + response.status
|
||||
throw new Error(message)
|
||||
}
|
||||
return unwrapApiPayload(payload)
|
||||
}
|
||||
|
||||
async function getStoredBrowserAgent() {
|
||||
const result = await chrome.storage.local.get(BROWSER_AGENT_STORAGE_KEY)
|
||||
const stored = result?.[BROWSER_AGENT_STORAGE_KEY]
|
||||
if (
|
||||
!stored
|
||||
|| typeof stored !== 'object'
|
||||
|| typeof stored.agentId !== 'string'
|
||||
|| typeof stored.token !== 'string'
|
||||
|| typeof stored.apiBaseUrl !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
async function saveStoredBrowserAgent(agent) {
|
||||
await chrome.storage.local.set({
|
||||
[BROWSER_AGENT_STORAGE_KEY]: agent,
|
||||
})
|
||||
}
|
||||
|
||||
function toPublicAgentState(agent, extra = {}) {
|
||||
if (!agent) {
|
||||
return {
|
||||
paired: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
return {
|
||||
paired: true,
|
||||
agentId: agent.agentId,
|
||||
apiBaseUrl: agent.apiBaseUrl,
|
||||
pairedAt: agent.pairedAt,
|
||||
runtime: agent.runtime || null,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRuntimeState(agent, runtime) {
|
||||
const next = {
|
||||
...agent,
|
||||
runtime: {
|
||||
...agent.runtime,
|
||||
...runtime,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
await saveStoredBrowserAgent(next)
|
||||
return next
|
||||
}
|
||||
|
||||
async function syncPollingAlarm() {
|
||||
await chrome.alarms.create(BROWSER_AGENT_ALARM_NAME, {
|
||||
periodInMinutes: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async function pairBrowserAgent(payload) {
|
||||
if (!payload || typeof payload !== 'object') throw new Error('缺少浏览器扩展配对信息')
|
||||
if (typeof payload.agentId !== 'string' || !payload.agentId.trim()) throw new Error('缺少浏览器 Agent ID')
|
||||
if (typeof payload.enrollmentCode !== 'string' || payload.enrollmentCode.length < 32) {
|
||||
throw new Error('浏览器扩展配对码无效')
|
||||
}
|
||||
|
||||
const apiBaseUrl = normalizeApiBaseUrl(payload.apiBaseUrl)
|
||||
const exchange = await requestBrowserAgent(
|
||||
{ apiBaseUrl },
|
||||
'/v2/channels/browser-agents/enroll',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agentId: payload.agentId,
|
||||
enrollmentCode: payload.enrollmentCode,
|
||||
extensionVersion: getExtensionVersion(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!exchange || typeof exchange.token !== 'string' || typeof exchange.agentId !== 'string') {
|
||||
throw new Error('浏览器 Agent 配对响应无效')
|
||||
}
|
||||
|
||||
const agent = {
|
||||
agentId: exchange.agentId,
|
||||
token: exchange.token,
|
||||
apiBaseUrl,
|
||||
pairedAt: new Date().toISOString(),
|
||||
runtime: {
|
||||
status: 'paired',
|
||||
message: '浏览器 Agent 已配对,等待可执行任务',
|
||||
},
|
||||
}
|
||||
await saveStoredBrowserAgent(agent)
|
||||
await syncPollingAlarm()
|
||||
return toPublicAgentState(agent)
|
||||
}
|
||||
|
||||
async function getBrowserAgentStatus() {
|
||||
const agent = await getStoredBrowserAgent()
|
||||
if (!agent) return toPublicAgentState(null)
|
||||
|
||||
try {
|
||||
const session = await requestBrowserAgent(agent, '/v2/channels/browser-agents/session/current')
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'online',
|
||||
session,
|
||||
lastError: undefined,
|
||||
})
|
||||
return toPublicAgentState(next, { session })
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'offline',
|
||||
lastError: getMessage(error, '无法连接浏览器 Agent 服务'),
|
||||
})
|
||||
return toPublicAgentState(next)
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectBrowserAgent() {
|
||||
await chrome.storage.local.remove(BROWSER_AGENT_STORAGE_KEY)
|
||||
return { paired: false, disconnected: true }
|
||||
}
|
||||
|
||||
async function hasCurrentPlatformTab(platformId) {
|
||||
const patterns = PLATFORM_TAB_PATTERNS[platformId] || []
|
||||
if (patterns.length === 0) return false
|
||||
const tabs = await chrome.tabs.query({ url: patterns })
|
||||
return tabs.some(tab => tab.id && !tab.discarded)
|
||||
}
|
||||
|
||||
async function inspectCurrentPlatformAccount(platformId) {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GET_PLATFORM_ACCOUNT',
|
||||
platformId,
|
||||
})
|
||||
if (!response?.success || !response.platformUid) {
|
||||
return {
|
||||
account: null,
|
||||
error: response?.error
|
||||
|| (response?.loggedIn
|
||||
? '已检测到登录状态,但平台没有返回稳定账号标识'
|
||||
: '平台登录状态未通过核对'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
account: {
|
||||
platform: platformId,
|
||||
platformUid: String(response.platformUid),
|
||||
displayName: response.displayName || String(response.platformUid),
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentPlatformAccount(platformId) {
|
||||
return (await inspectCurrentPlatformAccount(platformId)).account
|
||||
}
|
||||
|
||||
async function inspectCurrentPlatformAccounts() {
|
||||
const accounts = []
|
||||
const checks = []
|
||||
for (const platformId of BROWSER_AGENT_PLATFORM_IDS) {
|
||||
if (!await hasCurrentPlatformTab(platformId)) continue
|
||||
try {
|
||||
const result = await inspectCurrentPlatformAccount(platformId)
|
||||
if (result.account) {
|
||||
accounts.push(result.account)
|
||||
checks.push({
|
||||
platform: platformId,
|
||||
status: 'verified',
|
||||
message: '账号已核对',
|
||||
})
|
||||
} else {
|
||||
checks.push({
|
||||
platform: platformId,
|
||||
status: 'failed',
|
||||
message: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
platform: platformId,
|
||||
status: 'failed',
|
||||
message: getMessage(error, '平台账号检测失败'),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { accounts, checks }
|
||||
}
|
||||
|
||||
async function listCurrentPlatformAccounts() {
|
||||
return (await inspectCurrentPlatformAccounts()).accounts
|
||||
}
|
||||
|
||||
function toTaskContent(task) {
|
||||
const body = typeof task.body === 'string' ? task.body : ''
|
||||
const isHtml = /<[a-z][\w:-]*(?:\s[^<>]*)?>/i.test(body)
|
||||
return {
|
||||
title: typeof task.title === 'string' ? task.title : '',
|
||||
body,
|
||||
markdown: body,
|
||||
html: isHtml ? body : renderToutiaoMarkdown(body),
|
||||
plainText: isHtml ? body.replace(/<[^>]+>/g, ' ') : renderToutiaoPlainText(body),
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskResult(task, account, status, extra = {}) {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
status,
|
||||
platformUid: account.platformUid,
|
||||
extensionVersion: getExtensionVersion(),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
async function clickPlatformAction(platformId, tabId, publishMode, providerConfig) {
|
||||
const action = getPlatformAction(platformId, publishMode, providerConfig)
|
||||
if (!action) {
|
||||
return {
|
||||
success: false,
|
||||
message: '该平台未定义受控' + (publishMode === 'draft' ? '存草稿' : '发布') + '动作',
|
||||
}
|
||||
}
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (selector, text) => {
|
||||
const normalize = value => String(value || '').replace(/\s+/g, '')
|
||||
const button = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => normalize(candidate.textContent) === normalize(text))
|
||||
if (!button) return { success: false, message: '未找到目标操作按钮' }
|
||||
if (button.disabled || button.getAttribute('aria-disabled') === 'true') {
|
||||
return { success: false, message: '目标操作按钮当前不可用' }
|
||||
}
|
||||
button.click()
|
||||
return { success: true }
|
||||
},
|
||||
args: [action.selector, action.text],
|
||||
world: 'MAIN',
|
||||
})
|
||||
return result?.[0]?.result || { success: false, message: '无法执行平台操作' }
|
||||
}
|
||||
|
||||
async function inspectTaskState(task, tabId) {
|
||||
return await chrome.runtime.sendMessage({
|
||||
type: 'INSPECT_PLATFORM_TASK',
|
||||
platformId: task.platform,
|
||||
tabId,
|
||||
expectedPlatformUid: task.platformUid,
|
||||
})
|
||||
}
|
||||
|
||||
async function executeClaimedTask(task) {
|
||||
if (!BROWSER_AGENT_PLATFORM_IDS.includes(task.platform)) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前扩展版本未开放该平台的浏览器 Agent 执行器',
|
||||
}
|
||||
}
|
||||
const account = await getCurrentPlatformAccount(task.platform)
|
||||
if (!account) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前浏览器无法确认任务绑定的平台账号',
|
||||
}
|
||||
}
|
||||
if (account.platformUid !== task.platformUid) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前浏览器登录账号与任务绑定账号不一致,任务将等待租约过期后重新领取',
|
||||
}
|
||||
}
|
||||
if (Date.parse(task.handoff?.expiresAt || '') <= Date.now()) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: '浏览器发布任务租约已过期,未执行平台操作',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
let providerConfig
|
||||
try {
|
||||
providerConfig = task.handoff?.providerConfig === undefined
|
||||
? undefined
|
||||
: validateBrowserProviderSnapshot(task.handoff.providerConfig, task.platform)
|
||||
} catch (error) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: getMessage(error, '声明式 Browser Provider 配置无效'),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
let opened
|
||||
try {
|
||||
opened = await chrome.runtime.sendMessage({
|
||||
type: 'OPEN_PLATFORM_TASK',
|
||||
platformId: task.platform,
|
||||
content: toTaskContent(task),
|
||||
expectedPlatformUid: task.platformUid,
|
||||
providerConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: getMessage(error, '打开平台编辑器失败'),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (!opened?.success || !Number.isInteger(opened.tabId) || opened.tabId <= 0) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: opened?.error || opened?.message || '平台编辑器内容填充失败',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const executionPolicy = getTaskExecutionPolicy(
|
||||
task.platform,
|
||||
task.handoff.publishMode,
|
||||
opened.status,
|
||||
providerConfig,
|
||||
)
|
||||
if (executionPolicy !== 'perform_action') {
|
||||
const status = {
|
||||
report_published: 'published',
|
||||
report_draft_saved: 'draft_saved',
|
||||
report_uncertain: 'publication_uncertain',
|
||||
report_filled: 'filled',
|
||||
}[executionPolicy]
|
||||
const fallbackMessage = status === 'filled'
|
||||
? task.handoff.publishMode === 'publish'
|
||||
? '内容已自动填充,当前平台发布动作尚未完成受控验证,请在已打开的编辑器中人工确认发布'
|
||||
: '内容已自动填充,当前平台草稿动作尚未完成受控验证,请在已打开的编辑器中人工保存草稿'
|
||||
: undefined
|
||||
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, status, {
|
||||
tabId: opened.tabId,
|
||||
url: opened.url,
|
||||
message: opened.message || fallbackMessage,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const clickResult = await clickPlatformAction(
|
||||
task.platform,
|
||||
opened.tabId,
|
||||
task.handoff.publishMode,
|
||||
providerConfig,
|
||||
)
|
||||
if (!clickResult.success) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
tabId: opened.tabId,
|
||||
url: opened.url,
|
||||
message: clickResult.message || '平台操作未执行',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1600))
|
||||
const inspected = await inspectTaskState(task, opened.tabId)
|
||||
if (!inspected?.success && inspected?.status !== 'publication_uncertain') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: inspected?.error || '无法核对平台操作结果',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (task.handoff.publishMode === 'draft') {
|
||||
if (inspected.status === 'draft_saved') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'draft_saved', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'publication_uncertain', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: inspected?.message || '已点击存草稿,但平台未返回可确认的草稿结果',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (inspected.status === 'published') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'published', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (inspected.status === 'draft_saved') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'draft_saved', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'publication_uncertain', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: '已点击第一层发布按钮,但未确认最终公开结果;平台可能需要人工二次确认',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function pollBrowserAgentNow() {
|
||||
if (pollingPromise) return pollingPromise
|
||||
|
||||
pollingPromise = (async () => {
|
||||
const agent = await getStoredBrowserAgent()
|
||||
if (!agent) return { success: false, status: 'not_paired' }
|
||||
|
||||
const { accounts, checks: accountChecks } = await inspectCurrentPlatformAccounts()
|
||||
if (accounts.length === 0) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'waiting_for_platform_account',
|
||||
message: accountChecks.length > 0
|
||||
? `已打开 ${accountChecks.length} 个受支持平台,但账号核对未通过`
|
||||
: '未找到已打开的受支持平台标签页',
|
||||
lastError: undefined,
|
||||
accounts: [],
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: true, accounts: [], accountChecks })
|
||||
}
|
||||
|
||||
let claimed
|
||||
try {
|
||||
claimed = await requestBrowserAgent(agent, '/v2/channels/browser-agents/claim', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
extensionVersion: getExtensionVersion(),
|
||||
accounts: accounts.map(account => ({
|
||||
platform: account.platform,
|
||||
platformUid: account.platformUid,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'offline',
|
||||
lastError: getMessage(error, '领取浏览器任务失败'),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks })
|
||||
}
|
||||
|
||||
const task = claimed?.task
|
||||
if (!task) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'idle',
|
||||
message: '没有等待执行的浏览器发布任务',
|
||||
lastError: undefined,
|
||||
lastClaimAt: new Date().toISOString(),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: true, accounts, accountChecks, task: null })
|
||||
}
|
||||
|
||||
const execution = await executeClaimedTask(task)
|
||||
if (!execution.report) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'account_mismatch',
|
||||
message: execution.message,
|
||||
lastTaskId: task.taskId,
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
|
||||
}
|
||||
|
||||
try {
|
||||
await requestBrowserAgent(agent, '/v2/channels/browser-agents/' + encodeURIComponent(task.taskId) + '/result', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(execution.result),
|
||||
})
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: execution.result.status,
|
||||
message: execution.result.message,
|
||||
lastError: undefined,
|
||||
lastTaskId: task.taskId,
|
||||
lastResultAt: new Date().toISOString(),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, {
|
||||
success: true,
|
||||
accounts,
|
||||
accountChecks,
|
||||
taskId: task.taskId,
|
||||
result: execution.result,
|
||||
})
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'result_sync_failed',
|
||||
message: '平台操作已完成,但结果回写失败',
|
||||
lastError: getMessage(error, '浏览器任务结果回写失败'),
|
||||
lastTaskId: task.taskId,
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
|
||||
}
|
||||
})().finally(() => {
|
||||
pollingPromise = null
|
||||
})
|
||||
|
||||
return pollingPromise
|
||||
}
|
||||
|
||||
function installBrowserAgentRuntime() {
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
void syncPollingAlarm()
|
||||
})
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
void syncPollingAlarm()
|
||||
})
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === BROWSER_AGENT_ALARM_NAME) {
|
||||
void pollBrowserAgentNow()
|
||||
}
|
||||
})
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (![
|
||||
'PAIR_BROWSER_AGENT',
|
||||
'GET_BROWSER_AGENT_STATUS',
|
||||
'POLL_BROWSER_AGENT_NOW',
|
||||
'DISCONNECT_BROWSER_AGENT',
|
||||
].includes(request?.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
switch (request.type) {
|
||||
case 'PAIR_BROWSER_AGENT':
|
||||
sendResponse(await pairBrowserAgent(request.payload))
|
||||
return
|
||||
case 'GET_BROWSER_AGENT_STATUS':
|
||||
sendResponse(await getBrowserAgentStatus())
|
||||
return
|
||||
case 'POLL_BROWSER_AGENT_NOW':
|
||||
sendResponse(await pollBrowserAgentNow())
|
||||
return
|
||||
case 'DISCONNECT_BROWSER_AGENT':
|
||||
sendResponse(await disconnectBrowserAgent())
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
sendResponse({ error: getMessage(error, '浏览器 Agent 操作失败') })
|
||||
}
|
||||
})()
|
||||
return true
|
||||
})
|
||||
void syncPollingAlarm()
|
||||
}
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage && chrome.alarms) {
|
||||
installBrowserAgentRuntime()
|
||||
}
|
||||
|
||||
export {
|
||||
ALLOWED_API_ORIGINS,
|
||||
BROWSER_AGENT_PLATFORM_IDS,
|
||||
PLATFORM_TAB_PATTERNS,
|
||||
executeClaimedTask,
|
||||
getPlatformAction,
|
||||
getTaskExecutionPolicy,
|
||||
inspectCurrentPlatformAccounts,
|
||||
listCurrentPlatformAccounts,
|
||||
normalizeApiBaseUrl,
|
||||
toTaskContent,
|
||||
}
|
||||
+217
-399
@@ -1,8 +1,17 @@
|
||||
// 平台配置
|
||||
import { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS } from './core/platforms/index.js'
|
||||
import {
|
||||
AUTO_FILL_PLATFORM_IDS,
|
||||
INSPECT_HANDLERS,
|
||||
PLATFORMS,
|
||||
SYNC_HANDLERS,
|
||||
} from './core/platforms/index.js'
|
||||
import { canReturnExistingPlatformTaskState } from './core/task-state.js'
|
||||
import {
|
||||
syncDeclarativeBrowserProvider,
|
||||
validateBrowserProviderSnapshot,
|
||||
} from './core/declarative-browser-provider.js'
|
||||
import { qianfanIntercept } from './core/platforms/qianfan.js'
|
||||
import { convertAvatarToBase64 } from './detection/src/utils.js'
|
||||
import { detectSupportedPlatformUser } from './detection/src/supported-platforms.js'
|
||||
// [DISABLED] import { fillAlipayOpenContent } from '@cose/core/src/platforms/alipayopen.js'
|
||||
|
||||
// ===== Offscreen helper =====
|
||||
@@ -123,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)
|
||||
@@ -167,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
|
||||
@@ -191,7 +211,7 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
|
||||
return { error: e.message }
|
||||
}
|
||||
},
|
||||
args: [apiUrl, responseType],
|
||||
args: [apiUrl, responseType, method, body],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
@@ -226,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.
|
||||
@@ -481,13 +555,17 @@ async function handleMessage(request, sender) {
|
||||
console.log(`[COSE] handleMessage received type: ${request.type}`, request)
|
||||
switch (request.type) {
|
||||
case 'GET_PLATFORMS':
|
||||
return { platforms: PLATFORMS }
|
||||
case 'CHECK_PLATFORM_STATUS':
|
||||
return { status: await checkAllPlatforms(request.platforms || PLATFORMS) }
|
||||
case 'CHECK_PLATFORM_STATUS_PROGRESSIVE':
|
||||
return { platforms: PLATFORMS.filter(platform => platform.autoFillSupported === true) }
|
||||
case 'CHECK_PLATFORM_STATUS': {
|
||||
const platforms = getRequestedAutoFillPlatforms(request.platforms)
|
||||
return { status: await checkAllPlatforms(platforms) }
|
||||
}
|
||||
case 'CHECK_PLATFORM_STATUS_PROGRESSIVE': {
|
||||
// 渐进式检测:每个平台检测完成后立即返回结果
|
||||
checkAllPlatformsProgressive(request.platforms || PLATFORMS, sender.tab?.id)
|
||||
return { started: true, total: (request.platforms || PLATFORMS).length }
|
||||
const platforms = getRequestedAutoFillPlatforms(request.platforms)
|
||||
checkAllPlatformsProgressive(platforms, sender.tab?.id)
|
||||
return { started: true, total: platforms.length }
|
||||
}
|
||||
case 'GET_PLATFORM_ACCOUNT':
|
||||
return await getPlatformAccount(request.platformId)
|
||||
case 'OPEN_PLATFORM_TASK':
|
||||
@@ -495,7 +573,8 @@ async function handleMessage(request, sender) {
|
||||
request.platformId,
|
||||
request.content,
|
||||
request.expectedPlatformUid,
|
||||
request.tabId
|
||||
request.tabId,
|
||||
request.providerConfig
|
||||
)
|
||||
case 'INSPECT_PLATFORM_TASK':
|
||||
return await inspectPlatformTask(
|
||||
@@ -510,46 +589,44 @@ async function handleMessage(request, sender) {
|
||||
case 'SYNC_TO_PLATFORM':
|
||||
return await syncToPlatform(request.platformId, request.content, request.options)
|
||||
case 'CACHE_USER_INFO':
|
||||
// 缓存用户信息
|
||||
if (request.platform === 'xiaohongshu' && request.userInfo) {
|
||||
await chrome.storage.local.set({ xiaohongshu_user: request.userInfo })
|
||||
console.log('[COSE] 小红书用户信息已缓存:', request.userInfo.username)
|
||||
} else if (request.platform === 'alipayopen' && request.userInfo) {
|
||||
await chrome.storage.local.set({ alipayopen_user: request.userInfo })
|
||||
console.log('[COSE] 支付宝用户信息已缓存:', request.userInfo.username)
|
||||
} else if (request.platform === 'huaweicloud' && request.userInfo) {
|
||||
const hwcInfo = { ...request.userInfo }
|
||||
if (hwcInfo.avatar && hwcInfo.avatar.startsWith('http')) {
|
||||
hwcInfo.avatar = await convertAvatarToBase64(
|
||||
hwcInfo.avatar,
|
||||
'https://bbs.huaweicloud.com/'
|
||||
)
|
||||
}
|
||||
await chrome.storage.local.set({ huaweicloud_user: hwcInfo })
|
||||
console.log('[COSE] 华为云用户信息已缓存:', hwcInfo.username)
|
||||
} else if (request.platform === 'huaweidev' && request.userInfo) {
|
||||
const hwdInfo = { ...request.userInfo }
|
||||
if (hwdInfo.avatar && hwdInfo.avatar.startsWith('http')) {
|
||||
hwdInfo.avatar = await convertAvatarToBase64(
|
||||
hwdInfo.avatar,
|
||||
'https://developer.huawei.com/'
|
||||
)
|
||||
}
|
||||
await chrome.storage.local.set({ huaweidev_user: hwdInfo })
|
||||
console.log('[COSE] 华为开发者用户信息已缓存:', hwdInfo.username)
|
||||
if (request.platform !== 'wechat' || !request.userInfo?.platformUid) {
|
||||
return { success: false, error: '该平台未开放受控账号缓存' }
|
||||
}
|
||||
await chrome.storage.local.set({
|
||||
wechat_user: {
|
||||
loggedIn: true,
|
||||
platformUid: String(request.userInfo.platformUid),
|
||||
username: request.userInfo.username || '',
|
||||
avatar: request.userInfo.avatar || '',
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
default:
|
||||
return { error: 'Unknown message type' }
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestedAutoFillPlatforms(requestedPlatforms) {
|
||||
const requestedIds = Array.isArray(requestedPlatforms)
|
||||
? new Set(
|
||||
requestedPlatforms
|
||||
.map(platform => (typeof platform === 'string' ? platform : platform?.id))
|
||||
.filter(platformId => typeof platformId === 'string' && platformId.trim()),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return PLATFORMS.filter(platform => {
|
||||
return platform.autoFillSupported === true
|
||||
&& (!requestedIds || requestedIds.has(platform.id))
|
||||
})
|
||||
}
|
||||
|
||||
// 检查所有平台登录状态
|
||||
async function checkAllPlatforms(platforms) {
|
||||
const status = {}
|
||||
try {
|
||||
// 过滤掉无效的平台配置
|
||||
const validPlatforms = (platforms || []).filter(p => p && p.id)
|
||||
const validPlatforms = getRequestedAutoFillPlatforms(platforms)
|
||||
const results = await Promise.allSettled(
|
||||
validPlatforms.map(async platform => {
|
||||
try {
|
||||
@@ -573,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
|
||||
|
||||
@@ -638,14 +715,12 @@ async function checkAllPlatformsProgressive(platforms, tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
import { detectUser } from './detection/index.js'
|
||||
|
||||
// 检查单个平台登录状态
|
||||
async function checkPlatformLogin(platform) {
|
||||
if (!platform || !platform.id) {
|
||||
if (!platform || !AUTO_FILL_PLATFORM_IDS.has(platform.id)) {
|
||||
return { loggedIn: false, error: '无效的平台配置' }
|
||||
}
|
||||
return await detectUser(platform.id)
|
||||
return await detectSupportedPlatformUser(platform.id)
|
||||
}
|
||||
|
||||
async function getPlatformAccount(platformId) {
|
||||
@@ -653,8 +728,14 @@ async function getPlatformAccount(platformId) {
|
||||
if (!platform) {
|
||||
return { success: false, error: '暂不支持该平台' }
|
||||
}
|
||||
if (platform.autoFillSupported !== true) {
|
||||
return {
|
||||
success: false,
|
||||
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。',
|
||||
}
|
||||
}
|
||||
|
||||
const account = await detectUser(platformId)
|
||||
const account = await checkPlatformLogin(platform)
|
||||
if (!account?.loggedIn) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -694,22 +775,42 @@ function validateExpectedPlatformAccount(account, expectedPlatformUid) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId) {
|
||||
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId, providerConfig) {
|
||||
if (!content || typeof content !== 'object') {
|
||||
return { success: false, error: '缺少待填充内容' }
|
||||
}
|
||||
|
||||
const platform = PLATFORMS.find(item => item?.id === platformId)
|
||||
if (!platform || !platform.publishUrl) {
|
||||
return { success: false, error: '暂不支持该平台' }
|
||||
}
|
||||
if (platform.autoFillSupported !== true) {
|
||||
return {
|
||||
success: false,
|
||||
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
|
||||
}
|
||||
}
|
||||
|
||||
const account = await getPlatformAccount(platformId)
|
||||
if (!account.success) return account
|
||||
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
|
||||
if (mismatch) return mismatch
|
||||
|
||||
let validatedProviderConfig
|
||||
try {
|
||||
validatedProviderConfig = providerConfig === undefined
|
||||
? undefined
|
||||
: validateBrowserProviderSnapshot(providerConfig, platformId)
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message || '声明式 Browser Provider 配置无效' }
|
||||
}
|
||||
|
||||
const inspect = INSPECT_HANDLERS[platformId]
|
||||
let reusableTabId
|
||||
if (inspect && Number.isInteger(existingTabId) && existingTabId > 0) {
|
||||
try {
|
||||
const existingTab = await chrome.tabs.get(existingTabId)
|
||||
const existingState = inspect(existingTab.url || '')
|
||||
const existingState = await inspect(existingTab, { chrome })
|
||||
if (canReturnExistingPlatformTaskState(existingState)) {
|
||||
await chrome.tabs.update(existingTabId, { active: true })
|
||||
return {
|
||||
@@ -729,14 +830,15 @@ async function openPlatformTask(platformId, content, expectedPlatformUid, existi
|
||||
const result = await syncToPlatform(platformId, content, {
|
||||
active: true,
|
||||
tabId: reusableTabId,
|
||||
providerConfig: validatedProviderConfig,
|
||||
})
|
||||
if (!result?.success) return result
|
||||
if (result.tabId) {
|
||||
await chrome.tabs.update(result.tabId, { active: true })
|
||||
}
|
||||
const tab = result.tabId ? await chrome.tabs.get(result.tabId) : null
|
||||
const state = inspect && tab?.url
|
||||
? inspect(tab.url)
|
||||
const state = inspect && tab
|
||||
? await inspect(tab, { chrome })
|
||||
: { status: 'filled' }
|
||||
return {
|
||||
...result,
|
||||
@@ -753,6 +855,17 @@ async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
|
||||
return { success: false, error: '缺少有效的平台编辑器标签页' }
|
||||
}
|
||||
|
||||
const platform = PLATFORMS.find(item => item?.id === platformId)
|
||||
if (!platform || !platform.publishUrl) {
|
||||
return { success: false, error: '暂不支持该平台' }
|
||||
}
|
||||
if (platform.autoFillSupported !== true) {
|
||||
return {
|
||||
success: false,
|
||||
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
|
||||
}
|
||||
}
|
||||
|
||||
const account = await getPlatformAccount(platformId)
|
||||
if (!account.success) return account
|
||||
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
|
||||
@@ -769,7 +882,7 @@ async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
|
||||
} catch {
|
||||
return { success: false, error: '平台编辑器标签页已经关闭,请重新打开并填充' }
|
||||
}
|
||||
const state = inspect(tab.url || '')
|
||||
const state = await inspect(tab, { chrome })
|
||||
return {
|
||||
success: state.status !== 'failed',
|
||||
...state,
|
||||
@@ -847,10 +960,30 @@ async function syncToPlatform(platformId, content, options = {}) {
|
||||
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) {
|
||||
@@ -882,90 +1015,7 @@ async function syncToPlatform(platformId, content, options = {}) {
|
||||
|
||||
// ==== 以下是原有的平台特定逻辑(待迁移)====
|
||||
|
||||
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] 开始处理小红书同步...')
|
||||
|
||||
@@ -1722,22 +1772,8 @@ async function syncToPlatform(platformId, content, options = {}) {
|
||||
} else [DISABLED] */
|
||||
if (platformId !== 'wechat' && !tab) {
|
||||
// 其他平台(排除微信,因为微信在上面已经处理)
|
||||
let targetUrl = platform.publishUrl
|
||||
|
||||
// 开源中国:使用 ai-write 编辑器,需要用户 ID
|
||||
if (platformId === 'oschina') {
|
||||
const stored = await chrome.storage.local.get('oschina_userId')
|
||||
const userId = stored?.oschina_userId
|
||||
if (userId) {
|
||||
targetUrl = `https://my.oschina.net/u/${userId}/blog/ai-write`
|
||||
console.log('[COSE] 使用 OSChina AI 写作 URL:', targetUrl)
|
||||
} else {
|
||||
console.warn('[COSE] 未找到 OSChina 用户 ID,使用默认 URL')
|
||||
}
|
||||
}
|
||||
|
||||
// 直接打开发布页面
|
||||
tab = await chrome.tabs.create({ url: targetUrl, active: false })
|
||||
tab = await chrome.tabs.create({ url: platform.publishUrl, active: false })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
await waitForTab(tab.id)
|
||||
}
|
||||
@@ -2272,68 +2308,6 @@ async function syncToPlatform(platformId, content, options = {}) {
|
||||
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') {
|
||||
// 等待页面完全加载
|
||||
@@ -3626,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')) {
|
||||
// 等待页面加载
|
||||
@@ -3857,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,
|
||||
}
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,72 +1,333 @@
|
||||
// 51CTO 平台配置
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const CTO51Platform = {
|
||||
id: 'cto51',
|
||||
id: '51cto',
|
||||
name: '51CTO',
|
||||
icon: 'https://blog.51cto.com/favicon.ico',
|
||||
url: 'https://blog.51cto.com',
|
||||
loginUrl: 'https://home.51cto.com/index/login',
|
||||
publishUrl: 'https://blog.51cto.com/blogger/publish',
|
||||
title: '51CTO',
|
||||
type: 'cto51',
|
||||
type: '51cto',
|
||||
}
|
||||
|
||||
// 51CTO 内容填充函数
|
||||
async function fillCTO51Content(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
const contentToFill = markdown || body || ''
|
||||
function parseCto51PublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
// 1. 填充标题
|
||||
// 51CTO 标题输入框通常是 input#title 或 placeholder="请输入标题"
|
||||
const titleInput = await waitFor('#title, input[placeholder*="标题"]')
|
||||
if (titleInput) {
|
||||
setInputValue(titleInput, title)
|
||||
console.log('[COSE] 51CTO 标题填充成功')
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://blog.51cto.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.51cto.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/u_(\d+)\/(\d+)\/?$/)
|
||||
if (!published?.[2]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[2],
|
||||
message: '已确认 51CTO 文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectCto51TaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseCto51PublishedUrl(publicUrl) || parseCto51PublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取 51CTO 编辑器标签页地址' }
|
||||
}
|
||||
|
||||
// 2. 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.51cto.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开 51CTO,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 填充内容
|
||||
// 51CTO 有 Markdown 编辑器和富文本编辑器,通常默认 Markdown
|
||||
// 尝试寻找 Markdown 编辑器的 textarea 或 CodeMirror
|
||||
const editor =
|
||||
document.querySelector('.editormd-markdown-textarea') || // Editor.md
|
||||
document.querySelector('#my-editormd-markdown-doc') || // 常见 ID
|
||||
document.querySelector('.CodeMirror textarea') || // CodeMirror 核心
|
||||
document.querySelector('textarea[name="content"]') // 通用 fallback
|
||||
|
||||
if (editor) {
|
||||
// 如果是 CodeMirror,通常需要操作 DOM 或使用 setValue
|
||||
// 尝试直接设置 value 并触发事件
|
||||
editor.focus()
|
||||
editor.value = contentToFill
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
// 如果页面上有 editor.md 的全局实例,尝试调用
|
||||
// 这需要在 page context 执行,目前 fillContentOnPage 是在 Main world 执行的,所以可以访问 window
|
||||
if (window.editor) {
|
||||
try {
|
||||
window.editor.setMarkdown(contentToFill)
|
||||
console.log('[COSE] 51CTO 通过 window.editor 设置成功')
|
||||
return
|
||||
} catch (e) {
|
||||
console.log('[COSE] 51CTO window.editor 调用失败', e)
|
||||
if (/^\/blogger\/publish\/?$/.test(url.pathname)) {
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认 51CTO 编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[COSE] 51CTO textarea 填充尝试完成')
|
||||
} else {
|
||||
console.log('[COSE] 51CTO 未找到编辑器元素,尝试降级 contenteditable')
|
||||
|
||||
// 可能是富文本模式的 contenteditable
|
||||
const contentEditable = document.querySelector('[contenteditable="true"]')
|
||||
if (contentEditable) {
|
||||
contentEditable.innerHTML = contentToFill.replace(/\n/g, '<br>')
|
||||
console.log('[COSE] 51CTO contenteditable 填充成功')
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到 51CTO 编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前 51CTO 页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { CTO51Platform, fillCTO51Content }
|
||||
function fillCto51Content(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'#title',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到 51CTO 标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(100)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '51CTO 未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const codeMirrorEditor = codeMirror?.CodeMirror
|
||||
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
|
||||
codeMirrorEditor.focus()
|
||||
codeMirrorEditor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
}
|
||||
|
||||
const markdownTextarea = await waitForElement([
|
||||
'.editormd-markdown-textarea',
|
||||
'textarea[name="content"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (markdownTextarea) {
|
||||
setInputValue(markdownTextarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(markdownTextarea.value)) {
|
||||
return { success: true, method: 'markdown-textarea' }
|
||||
}
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '51CTO 未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectCto51Task(tab, helpers) {
|
||||
const currentState = inspectCto51TaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'blog.51cto.com'
|
||||
&& /^\/u_\d+\/\d+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const feedbackText = Array.from(document.querySelectorAll(
|
||||
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
|
||||
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectCto51TaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCto51Content(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取 51CTO 编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillCto51Content,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '51CTO 内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充 51CTO 编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
CTO51Platform,
|
||||
fillCto51Content,
|
||||
inspectCto51Task,
|
||||
inspectCto51TaskState,
|
||||
parseCto51PublishedUrl,
|
||||
syncCto51Content,
|
||||
}
|
||||
|
||||
@@ -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 { 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,57 +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 = {
|
||||
juejin: inspectJuejinTaskState,
|
||||
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 { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
|
||||
export {
|
||||
AUTO_FILL_PLATFORM_IDS,
|
||||
INSPECT_HANDLERS,
|
||||
PLATFORMS,
|
||||
LOGIN_CHECK_CONFIG,
|
||||
SYNC_HANDLERS,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ const JuejinPlatform = {
|
||||
type: 'juejin',
|
||||
}
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
function inspectJuejinTaskState(rawUrl) {
|
||||
let url
|
||||
try {
|
||||
@@ -60,43 +58,110 @@ function inspectJuejinTaskState(rawUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
// 掘金内容填充函数(在页面主世界中执行)
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
function fillJuejinContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
input.focus()
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
// 填充标题(使用注入的 window.waitFor)
|
||||
const titleInput = await window.waitFor('input[placeholder*="标题"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
titleInput.value = title
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 掘金使用 ByteMD 编辑器(基于 CodeMirror)
|
||||
const cmElement = document.querySelector('.CodeMirror')
|
||||
if (cmElement && cmElement.CodeMirror) {
|
||||
cmElement.CodeMirror.setValue(contentToFill)
|
||||
console.log('[COSE] 掘金 CodeMirror 填充成功')
|
||||
return { success: true, method: 'CodeMirror' }
|
||||
} else {
|
||||
// 降级到 textarea
|
||||
const textarea = document.querySelector('.bytemd-body textarea')
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.value = contentToFill
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
console.log('[COSE] 掘金 textarea 填充成功')
|
||||
return { success: true, method: 'textarea' }
|
||||
} else {
|
||||
console.log('[COSE] 掘金 未找到编辑器')
|
||||
return { success: false, error: 'Editor not found' }
|
||||
const titleInput = title
|
||||
? await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'.title-input input',
|
||||
'input.title-input',
|
||||
])
|
||||
: null
|
||||
if (title) {
|
||||
if (!titleInput) return { success: false, error: '未找到掘金标题输入框' }
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '掘金未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const cmElement = await waitForElement(['.CodeMirror'], titleInput, 3000)
|
||||
if (cmElement && cmElement.CodeMirror) {
|
||||
cmElement.CodeMirror.setValue(contentToFill)
|
||||
cmElement.CodeMirror.focus?.()
|
||||
await sleep(300)
|
||||
if (normalizeContent(cmElement.CodeMirror.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
return { success: false, error: '掘金未确认接收正文' }
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.bytemd-body textarea',
|
||||
'.bytemd-editor textarea',
|
||||
'.bytemd-split .bytemd-body textarea',
|
||||
], titleInput, 4000)
|
||||
if (!textarea) return { success: false, error: '未找到掘金 Markdown 编辑器' }
|
||||
|
||||
setInputValue(textarea, contentToFill)
|
||||
await sleep(300)
|
||||
if (normalizeContent(textarea.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '掘金未确认接收正文' }
|
||||
}
|
||||
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
@@ -112,13 +177,8 @@ function fillJuejinContent(title, markdown, body) {
|
||||
async function syncJuejinContent(tab, content, helpers) {
|
||||
const { chrome } = helpers
|
||||
|
||||
// 等待页面加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
await helpers.waitForTab(tab.id)
|
||||
|
||||
// 先注入公共工具函数(waitFor, setInputValue)
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 在页面中执行填充脚本
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillJuejinContent,
|
||||
|
||||
@@ -1,85 +1,317 @@
|
||||
// OSChina 平台配置
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const OSChinaPlatform = {
|
||||
id: 'oschina',
|
||||
name: 'OSChina',
|
||||
icon: 'https://wsrv.nl/?url=static.oschina.net/new-osc/img/favicon.ico',
|
||||
icon: 'https://www.oschina.net/favicon.ico',
|
||||
url: 'https://www.oschina.net',
|
||||
publishUrl: 'https://my.oschina.net/blog/ai-write',
|
||||
title: '开源中国',
|
||||
type: 'oschina',
|
||||
}
|
||||
|
||||
// OSChina 内容填充函数 (AI 写作平台 - 切换到 Markdown 编辑器)
|
||||
async function fillOSChinaContent(content, waitFor, setInputValue) {
|
||||
const { title, markdown, body } = content
|
||||
const mdContent = markdown || body || ''
|
||||
function isOSChinaEditorUrl(url) {
|
||||
return url.protocol === 'https:'
|
||||
&& url.hostname === 'my.oschina.net'
|
||||
&& /^\/(?:blog\/ai-write|u\/\d+\/blog\/ai-write|u\/draft\/new)\/?$/.test(url.pathname)
|
||||
}
|
||||
|
||||
// 1. 切换到 MD 编辑器(如果当前不是)
|
||||
const switchText = document.querySelector('.editor-switch-text')
|
||||
if (switchText && switchText.textContent.includes('切换到MD编辑器')) {
|
||||
const switchBtn = document.querySelector('.editor-switch-btn') || switchText.parentElement
|
||||
if (switchBtn) {
|
||||
switchBtn.click()
|
||||
let confirmBtn = null
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
confirmBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
btn => btn.textContent.trim() === '确定切换'
|
||||
)
|
||||
if (confirmBtn) break
|
||||
}
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click()
|
||||
console.log('[COSE] OSChina 已确认切换到MD编辑器')
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
/**
|
||||
* 开源中国的公开文章地址尚未经过真实创作者账号验证。
|
||||
* 因此只确认编辑器和草稿状态,避免把未知页面误记为已发布。
|
||||
*/
|
||||
function inspectOSChinaTaskState(rawUrl, draftSaved = false) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取开源中国编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (!isOSChinaEditorUrl(url)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前开源中国页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 填充标题
|
||||
const titleInput = await waitFor('input[placeholder*="标题"]')
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(titleInput, title)
|
||||
} else {
|
||||
titleInput.value = title
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认开源中国编辑器显示草稿保存成功',
|
||||
}
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] OSChina 标题填充成功')
|
||||
}
|
||||
|
||||
// 3. 填充 Markdown 内容到 textarea
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
let textarea = null
|
||||
for (let i = 0; i < 10; i++) {
|
||||
textarea = document.querySelector('textarea')
|
||||
if (textarea) break
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
}
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (textareaSetter) {
|
||||
textareaSetter.call(textarea, mdContent)
|
||||
} else {
|
||||
textarea.value = mdContent
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] OSChina Markdown 内容填充成功')
|
||||
} else {
|
||||
console.log('[COSE] OSChina 未找到 Markdown textarea')
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到开源中国编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { OSChinaPlatform, fillOSChinaContent }
|
||||
function fillOSChinaContent(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function switchToMarkdownEditor() {
|
||||
const switchButton = Array.from(document.querySelectorAll('button, [role="button"], a'))
|
||||
.find(element => {
|
||||
const text = element.textContent?.replace(/\s+/g, '') || ''
|
||||
return /切换到(?:Markdown|MD)编辑器|(?:Markdown|MD)编辑器/.test(text)
|
||||
})
|
||||
|| document.querySelector('.editor-switch-btn')
|
||||
if (!switchButton) return
|
||||
|
||||
switchButton.click()
|
||||
await sleep(500)
|
||||
const confirmButton = Array.from(document.querySelectorAll('button')).find(button =>
|
||||
/确定切换|确认切换/.test(button.textContent?.trim() || ''),
|
||||
)
|
||||
if (confirmButton) {
|
||||
confirmButton.click()
|
||||
await sleep(1200)
|
||||
}
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
'.title-input input',
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到开源中国标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(100)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '开源中国未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const codeMirrorEditor = codeMirror?.CodeMirror
|
||||
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
|
||||
codeMirrorEditor.focus()
|
||||
codeMirrorEditor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.editormd-markdown-textarea',
|
||||
'.markdown-editor textarea',
|
||||
'textarea[name="content"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (textarea) {
|
||||
setInputValue(textarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: 'markdown-textarea' }
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.cke_editable[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '开源中国未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectOSChinaTask(tab, helpers) {
|
||||
const currentState = inspectOSChinaTaskState(tab?.url || '')
|
||||
if (!tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const feedbackText = Array.from(document.querySelectorAll(
|
||||
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
|
||||
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
|
||||
return {
|
||||
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
|
||||
}
|
||||
},
|
||||
})
|
||||
return inspectOSChinaTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOSChinaContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取开源中国编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillOSChinaContent,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '开源中国内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充开源中国编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
OSChinaPlatform,
|
||||
fillOSChinaContent,
|
||||
inspectOSChinaTask,
|
||||
inspectOSChinaTaskState,
|
||||
syncOSChinaContent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
const QiehaoPlatform = {
|
||||
id: 'qiehao',
|
||||
name: 'Qiehao',
|
||||
icon: 'https://om.qq.com/favicon.ico',
|
||||
url: 'https://om.qq.com/',
|
||||
publishUrl: 'https://om.qq.com/main/creation/article',
|
||||
title: '企鹅号',
|
||||
type: 'qiehao',
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getQiehaoDraftId(url) {
|
||||
return normalizeString(
|
||||
url.searchParams.get('draftId')
|
||||
|| url.searchParams.get('draft_id')
|
||||
|| url.searchParams.get('articleId')
|
||||
|| url.searchParams.get('article_id'),
|
||||
)
|
||||
}
|
||||
|
||||
function parseQiehaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'page.om.qq.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/page\/([^/.]+)\.html$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认企鹅号文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectQiehaoTaskState(rawUrl, draftSaved = false) {
|
||||
const published = parseQiehaoPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取企鹅号编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'om.qq.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开企鹅号编辑器,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/main/creation/article') {
|
||||
const draftId = getQiehaoDraftId(url)
|
||||
if (draftSaved || draftId) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: draftId
|
||||
? '已确认企鹅号草稿地址'
|
||||
: '已确认企鹅号编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到企鹅号编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前企鹅号页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillQiehaoContent(title, html, plainText) {
|
||||
const contentHtml = html || ''
|
||||
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = contentHtml
|
||||
? htmlToComparableText(contentHtml)
|
||||
: normalizeComparableText(contentText)
|
||||
const expectedImageCount = Array.from(contentHtml.matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors
|
||||
.map(selector => document.querySelector(selector))
|
||||
.find(element => element && element.offsetParent !== null)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditor(editor, nextHtml, nextText) {
|
||||
editor.focus()
|
||||
selectContents(editor)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedTitle(editor) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(editor?.textContent) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const actualImageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || actualImageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleEditor = await waitForElement([
|
||||
'.omui-inputautogrowing__inner[contenteditable="true"][data-placeholder*="请输入标题"]',
|
||||
])
|
||||
if (!titleEditor) return { success: false, error: '未找到企鹅号标题输入框' }
|
||||
if (title) {
|
||||
updateEditor(titleEditor, String(title).replace(/</g, '<'), title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleEditor)) return { success: false, error: '企鹅号未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentHtml) return { success: true, method: 'title-only' }
|
||||
|
||||
const articleEditor = await waitForElement([
|
||||
'.ProseMirror.ExEditor-basic[contenteditable="true"]',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
])
|
||||
if (!articleEditor) return { success: false, error: '未找到企鹅号正文编辑器' }
|
||||
|
||||
updateEditor(articleEditor, contentHtml, contentText)
|
||||
await sleep(500)
|
||||
if (!hasExpectedContent(articleEditor)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '企鹅号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectQiehaoTask(tab, helpers) {
|
||||
const currentState = inspectQiehaoTaskState(tab?.url || '')
|
||||
if (!tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => ({
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|草稿保存成功/.test(
|
||||
document.body?.innerText || '',
|
||||
),
|
||||
}),
|
||||
})
|
||||
return inspectQiehaoTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncQiehaoContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取企鹅号编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const html = content.html || content.body || content.markdown || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillQiehaoContent,
|
||||
args: [content.title, html, content.plainText || html.replace(/<[^>]+>/g, ' ')],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '企鹅号内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充企鹅号文章编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fillQiehaoContent,
|
||||
inspectQiehaoTask,
|
||||
inspectQiehaoTaskState,
|
||||
parseQiehaoPublishedUrl,
|
||||
QiehaoPlatform,
|
||||
syncQiehaoContent,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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] 已触发 ClipboardEvent,dispatched:', 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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -27,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,
|
||||
}
|
||||
|
||||
// 抖音
|
||||
|
||||
@@ -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(/ /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 }
|
||||
@@ -28,13 +28,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_DETECT_CTO51') {
|
||||
handleDetectCto51()
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_DETECT_CNBLOGS') {
|
||||
handleDetectCnblogs()
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
@@ -121,48 +114,6 @@ async function handleApiFetch(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 51CTO detection: fetch home.51cto.com/space and parse with DOMParser.
|
||||
* Same approach as 爱贝壳 extension - runs in document context (offscreen).
|
||||
*/
|
||||
async function handleDetectCto51() {
|
||||
try {
|
||||
const resp = await fetch('https://home.51cto.com/space', {
|
||||
credentials: 'include',
|
||||
})
|
||||
const html = await resp.text()
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
|
||||
// Avatar: <img alt="头像">
|
||||
const avatarEl = doc.querySelector("img[alt='头像']")
|
||||
const avatar = avatarEl ? avatarEl.getAttribute('src') : ''
|
||||
|
||||
// UID from avatar URL: uid=(\d+)
|
||||
let uid = ''
|
||||
if (avatar) {
|
||||
const m = avatar.match(/uid=(\d+)/)
|
||||
if (m) uid = m[1]
|
||||
}
|
||||
|
||||
// Nickname: div.name > a
|
||||
const nameEl = doc.querySelector('div.name > a')
|
||||
const username = nameEl ? nameEl.textContent.trim() : ''
|
||||
|
||||
if (!username && !uid) {
|
||||
// Return debug info to help diagnose
|
||||
const title = doc.querySelector('title')?.textContent || ''
|
||||
return {
|
||||
loggedIn: false,
|
||||
_debug: { status: resp.status, url: resp.url, htmlLen: html.length, title },
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar, uid }
|
||||
} catch (e) {
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cnblogs detection: fetch account.cnblogs.com/user/userinfo in document context.
|
||||
* Cookies are sent automatically (unlike service worker fetch which strips Cookie headers).
|
||||
|
||||
+6
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AiToEarn - 内容营销助手",
|
||||
"version": "1.4.0",
|
||||
"version": "1.19.6",
|
||||
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"alarms",
|
||||
"clipboardRead",
|
||||
"cookies",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
"debugger",
|
||||
@@ -21,6 +21,8 @@
|
||||
"http://localhost:6061/*",
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*",
|
||||
"http://localhost:3002/*",
|
||||
"https://wxapi.frp.it1024.cc/*",
|
||||
"https://*.csdn.net/*",
|
||||
"https://*.juejin.cn/*",
|
||||
"https://*.jianshu.com/*",
|
||||
@@ -45,6 +47,8 @@
|
||||
"https://*.baijiahao.baidu.com/*",
|
||||
"https://*.163.com/*",
|
||||
"https://*.cloud.tencent.com/*",
|
||||
"https://om.qq.com/*",
|
||||
"https://page.om.qq.com/*",
|
||||
"https://*.medium.com/*",
|
||||
"https://*.sohu.com/*",
|
||||
"https://*.aliyun.com/*",
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ async function loadSettings() {
|
||||
]);
|
||||
if (response?.error) throw new Error(response.error);
|
||||
availablePlatforms = Array.isArray(response?.platforms)
|
||||
? response.platforms.filter(platform => platform?.id && platform?.publishUrl)
|
||||
? response.platforms.filter(platform => platform?.id && platform?.publishUrl && platform.autoFillSupported === true)
|
||||
: [];
|
||||
const availableIds = new Set(availablePlatforms.map(platform => platform.id));
|
||||
const selectedIds = preferences.defaultPlatforms.filter(platformId => availableIds.has(platformId));
|
||||
|
||||
+3
-2
@@ -3,7 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs",
|
||||
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1"
|
||||
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs && node scripts/test-browser-agent.mjs",
|
||||
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1",
|
||||
"release:verify": "node scripts/verify-release.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ async function loadPlatforms() {
|
||||
chrome.storage.sync.get({ defaultPlatforms: ['wechat'] }),
|
||||
]);
|
||||
availablePlatforms = Array.isArray(response?.platforms)
|
||||
? response.platforms.filter(platform => platform?.id && platform?.publishUrl)
|
||||
? response.platforms.filter(platform => platform?.id && platform?.publishUrl && platform.autoFillSupported === true)
|
||||
: [];
|
||||
const availableIds = new Set(availablePlatforms.map(platform => platform.id));
|
||||
const selectedIds = preferences.defaultPlatforms.filter(platformId => availableIds.has(platformId));
|
||||
|
||||
+66
-1
@@ -60,6 +60,11 @@ if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Distribution bridge validation failed'
|
||||
}
|
||||
|
||||
& node (Join-Path $PSScriptRoot 'test-browser-agent.mjs')
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Browser Agent validation failed'
|
||||
}
|
||||
|
||||
$distRoot = Join-Path $repoRoot 'dist'
|
||||
$stageRoot = Join-Path $distRoot "aitoearn-extension-v$version"
|
||||
$archivePath = Join-Path $distRoot "aitoearn-extension-v$version.zip"
|
||||
@@ -89,7 +94,67 @@ foreach ($relativePath in $requiredPaths) {
|
||||
}
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $stageRoot '*') -DestinationPath $archivePath -CompressionLevel Optimal
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
|
||||
$archiveStream = $null
|
||||
$archive = $null
|
||||
try {
|
||||
$archiveStream = [System.IO.File]::Open(
|
||||
$archivePath,
|
||||
[System.IO.FileMode]::CreateNew,
|
||||
[System.IO.FileAccess]::ReadWrite,
|
||||
[System.IO.FileShare]::None
|
||||
)
|
||||
$archive = [System.IO.Compression.ZipArchive]::new(
|
||||
$archiveStream,
|
||||
[System.IO.Compression.ZipArchiveMode]::Create,
|
||||
$false,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
$fixedTimestamp = [System.DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [System.TimeSpan]::Zero)
|
||||
$stageRootFullPath = [System.IO.Path]::GetFullPath($stageRoot)
|
||||
$archiveFiles = Get-ChildItem -LiteralPath $stageRoot -Recurse -File |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
EntryName = $_.FullName.Substring($stageRootFullPath.Length + 1).Replace('\', '/')
|
||||
SourcePath = $_.FullName
|
||||
}
|
||||
} |
|
||||
Sort-Object -Property EntryName
|
||||
|
||||
foreach ($archiveFile in $archiveFiles) {
|
||||
$entry = $archive.CreateEntry(
|
||||
$archiveFile.EntryName,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal
|
||||
)
|
||||
$entry.LastWriteTime = $fixedTimestamp
|
||||
$entry.ExternalAttributes = 0
|
||||
|
||||
$sourceStream = $null
|
||||
$entryStream = $null
|
||||
try {
|
||||
$sourceStream = [System.IO.File]::OpenRead($archiveFile.SourcePath)
|
||||
$entryStream = $entry.Open()
|
||||
$sourceStream.CopyTo($entryStream)
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $entryStream) {
|
||||
$entryStream.Dispose()
|
||||
}
|
||||
if ($null -ne $sourceStream) {
|
||||
$sourceStream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $archive) {
|
||||
$archive.Dispose()
|
||||
}
|
||||
if ($null -ne $archiveStream) {
|
||||
$archiveStream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$verificationRoot = Join-Path $distRoot '.verify'
|
||||
if (Test-Path -LiteralPath $verificationRoot) {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
ALLOWED_API_ORIGINS,
|
||||
BROWSER_AGENT_PLATFORM_IDS,
|
||||
PLATFORM_TAB_PATTERNS,
|
||||
executeClaimedTask,
|
||||
getPlatformAction,
|
||||
getTaskExecutionPolicy,
|
||||
inspectCurrentPlatformAccounts,
|
||||
listCurrentPlatformAccounts,
|
||||
normalizeApiBaseUrl,
|
||||
toTaskContent,
|
||||
} from '../distribution/browser-agent/index.js'
|
||||
import {
|
||||
getDeclarativeProviderAction,
|
||||
validateBrowserProviderSnapshot,
|
||||
} from '../distribution/cose/core/declarative-browser-provider.js'
|
||||
|
||||
assert.deepEqual(
|
||||
ALLOWED_API_ORIGINS,
|
||||
new Set([
|
||||
'https://wxapi.frp.it1024.cc',
|
||||
'http://localhost:3002',
|
||||
]),
|
||||
)
|
||||
assert.equal(normalizeApiBaseUrl('https://wxapi.frp.it1024.cc/'), 'https://wxapi.frp.it1024.cc')
|
||||
assert.equal(normalizeApiBaseUrl('http://localhost:3002'), 'http://localhost:3002')
|
||||
assert.throws(
|
||||
() => normalizeApiBaseUrl('https://api.example.com'),
|
||||
/已配置的 AiToEarn API 地址/,
|
||||
)
|
||||
assert.throws(
|
||||
() => normalizeApiBaseUrl('https://wxapi.frp.it1024.cc/v2'),
|
||||
/已配置的 AiToEarn API 地址/,
|
||||
)
|
||||
assert.deepEqual(BROWSER_AGENT_PLATFORM_IDS, [
|
||||
'csdn',
|
||||
'juejin',
|
||||
'jianshu',
|
||||
'zhihu',
|
||||
'toutiao',
|
||||
'baijiahao',
|
||||
'wangyi',
|
||||
'sohu',
|
||||
'infoq',
|
||||
'tencentcloud',
|
||||
'aliyun',
|
||||
'segmentfault',
|
||||
'51cto',
|
||||
'oschina',
|
||||
'qiehao',
|
||||
])
|
||||
assert.equal(
|
||||
BROWSER_AGENT_PLATFORM_IDS.every(platformId => PLATFORM_TAB_PATTERNS[platformId]?.length > 0),
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(getPlatformAction('qiehao', 'draft'), {
|
||||
selector: 'button.omui-button.omui-button--background-grey',
|
||||
text: '存草稿',
|
||||
})
|
||||
assert.deepEqual(getPlatformAction('qiehao', 'publish'), {
|
||||
selector: 'button.omui-button.omui-button--primary',
|
||||
text: '发布',
|
||||
})
|
||||
assert.equal(getPlatformAction('qiehao', 'unknown'), null)
|
||||
|
||||
const qiehaoProviderSnapshot = {
|
||||
pluginId: 'browser-providers',
|
||||
version: '1.0.0',
|
||||
sourceCommit: 'a'.repeat(40),
|
||||
contentHash: 'b'.repeat(64),
|
||||
definition: {
|
||||
platform: 'qiehao',
|
||||
title: { selector: '.title' },
|
||||
content: {
|
||||
selector: '.ProseMirror',
|
||||
editorType: 'contenteditable',
|
||||
format: 'html',
|
||||
},
|
||||
draftAction: { selector: 'button.plugin-draft', exactText: '保存草稿' },
|
||||
},
|
||||
}
|
||||
assert.deepEqual(getDeclarativeProviderAction(qiehaoProviderSnapshot, 'qiehao', 'draft'), {
|
||||
selector: 'button.plugin-draft',
|
||||
text: '保存草稿',
|
||||
})
|
||||
assert.deepEqual(getPlatformAction('qiehao', 'draft', qiehaoProviderSnapshot), {
|
||||
selector: 'button.plugin-draft',
|
||||
text: '保存草稿',
|
||||
})
|
||||
assert.throws(
|
||||
() => validateBrowserProviderSnapshot({
|
||||
...qiehaoProviderSnapshot,
|
||||
definition: {
|
||||
...qiehaoProviderSnapshot.definition,
|
||||
editorUrl: 'https://attacker.example/editor',
|
||||
},
|
||||
}, 'qiehao'),
|
||||
/不允许的字段/,
|
||||
)
|
||||
assert.throws(
|
||||
() => validateBrowserProviderSnapshot(qiehaoProviderSnapshot, 'csdn'),
|
||||
/任务平台不一致/,
|
||||
)
|
||||
assert.equal(getTaskExecutionPolicy('qiehao', 'draft', 'draft_saved'), 'report_draft_saved')
|
||||
assert.equal(getTaskExecutionPolicy('qiehao', 'draft', 'filled'), 'perform_action')
|
||||
assert.equal(getTaskExecutionPolicy('qiehao', 'publish', 'draft_saved'), 'perform_action')
|
||||
assert.equal(getTaskExecutionPolicy('juejin', 'draft', 'draft_saved'), 'report_draft_saved')
|
||||
assert.equal(getTaskExecutionPolicy('juejin', 'draft', 'filled'), 'report_filled')
|
||||
assert.equal(getTaskExecutionPolicy('juejin', 'publish', 'draft_saved'), 'report_draft_saved')
|
||||
assert.equal(getTaskExecutionPolicy('juejin', 'publish', 'publication_uncertain'), 'report_uncertain')
|
||||
assert.equal(getTaskExecutionPolicy('juejin', 'publish', 'published'), 'report_published')
|
||||
|
||||
const sentMessages = []
|
||||
let scriptExecutionCount = 0
|
||||
globalThis.chrome = {
|
||||
runtime: {
|
||||
getManifest: () => ({ version: '1.18.3' }),
|
||||
sendMessage: async (message) => {
|
||||
sentMessages.push(message)
|
||||
if (message.type === 'GET_PLATFORM_ACCOUNT') {
|
||||
return {
|
||||
success: true,
|
||||
platformUid: `${message.platformId}-uid`,
|
||||
displayName: `${message.platformId} account`,
|
||||
}
|
||||
}
|
||||
if (message.type === 'OPEN_PLATFORM_TASK') {
|
||||
return {
|
||||
success: true,
|
||||
status: 'filled',
|
||||
tabId: 101,
|
||||
url: 'https://juejin.cn/editor/drafts/new',
|
||||
message: '掘金编辑器已填充',
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected message: ${message.type}`)
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
query: async ({ url }) => url?.length ? [{ id: 1, discarded: false }] : [],
|
||||
},
|
||||
scripting: {
|
||||
executeScript: async () => {
|
||||
scriptExecutionCount += 1
|
||||
return []
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const listedAccounts = await listCurrentPlatformAccounts()
|
||||
assert.equal(listedAccounts.length, BROWSER_AGENT_PLATFORM_IDS.length)
|
||||
assert.deepEqual(
|
||||
listedAccounts.map(account => account.platform),
|
||||
BROWSER_AGENT_PLATFORM_IDS,
|
||||
)
|
||||
|
||||
const originalAccountMessageHandler = globalThis.chrome.runtime.sendMessage
|
||||
const originalTabQueryHandler = globalThis.chrome.tabs.query
|
||||
globalThis.chrome.tabs.query = async ({ url }) => {
|
||||
const patterns = Array.isArray(url) ? url : []
|
||||
return patterns.some(pattern => pattern.includes('csdn') || pattern.includes('aliyun'))
|
||||
? [{ id: 1, discarded: false }]
|
||||
: []
|
||||
}
|
||||
globalThis.chrome.runtime.sendMessage = async (message) => {
|
||||
if (message.type !== 'GET_PLATFORM_ACCOUNT') {
|
||||
throw new Error(`Unexpected message: ${message.type}`)
|
||||
}
|
||||
if (message.platformId === 'csdn') {
|
||||
return {
|
||||
success: true,
|
||||
platformUid: 'csdn-uid',
|
||||
displayName: 'CSDN account',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
loggedIn: true,
|
||||
error: '已检测到登录状态,但平台没有返回稳定账号 UID',
|
||||
}
|
||||
}
|
||||
|
||||
const inspectedAccounts = await inspectCurrentPlatformAccounts()
|
||||
assert.deepEqual(inspectedAccounts.accounts, [{
|
||||
platform: 'csdn',
|
||||
platformUid: 'csdn-uid',
|
||||
displayName: 'CSDN account',
|
||||
}])
|
||||
assert.deepEqual(inspectedAccounts.checks, [
|
||||
{
|
||||
platform: 'csdn',
|
||||
status: 'verified',
|
||||
message: '账号已核对',
|
||||
},
|
||||
{
|
||||
platform: 'aliyun',
|
||||
status: 'failed',
|
||||
message: '已检测到登录状态,但平台没有返回稳定账号 UID',
|
||||
},
|
||||
])
|
||||
|
||||
globalThis.chrome.runtime.sendMessage = originalAccountMessageHandler
|
||||
globalThis.chrome.tabs.query = originalTabQueryHandler
|
||||
|
||||
sentMessages.length = 0
|
||||
const juejinProviderSnapshot = {
|
||||
...qiehaoProviderSnapshot,
|
||||
definition: {
|
||||
platform: 'juejin',
|
||||
title: { selector: '.title-input' },
|
||||
content: {
|
||||
selector: '.CodeMirror',
|
||||
editorType: 'codemirror5',
|
||||
format: 'markdown',
|
||||
},
|
||||
},
|
||||
}
|
||||
const filledExecution = await executeClaimedTask({
|
||||
platform: 'juejin',
|
||||
platformUid: 'juejin-uid',
|
||||
title: '后台交接测试',
|
||||
body: '# 正文',
|
||||
handoff: {
|
||||
publishMode: 'draft',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
providerConfig: juejinProviderSnapshot,
|
||||
},
|
||||
})
|
||||
assert.equal(filledExecution.report, true)
|
||||
assert.equal(filledExecution.result.status, 'filled')
|
||||
assert.equal(filledExecution.result.platformUid, 'juejin-uid')
|
||||
assert.equal(filledExecution.result.tabId, 101)
|
||||
assert.equal(scriptExecutionCount, 0)
|
||||
assert.deepEqual(sentMessages.map(message => message.type), [
|
||||
'GET_PLATFORM_ACCOUNT',
|
||||
'OPEN_PLATFORM_TASK',
|
||||
])
|
||||
assert.deepEqual(
|
||||
sentMessages.find(message => message.type === 'OPEN_PLATFORM_TASK')?.providerConfig,
|
||||
juejinProviderSnapshot,
|
||||
)
|
||||
|
||||
const originalQiehaoMessageHandler = globalThis.chrome.runtime.sendMessage
|
||||
const originalQiehaoScriptHandler = globalThis.chrome.scripting.executeScript
|
||||
const qiehaoClickedActions = []
|
||||
const qiehaoActionArguments = []
|
||||
globalThis.chrome.runtime.sendMessage = async (message) => {
|
||||
sentMessages.push(message)
|
||||
if (message.type === 'GET_PLATFORM_ACCOUNT') {
|
||||
return {
|
||||
success: true,
|
||||
platformUid: '24973524',
|
||||
displayName: '企鹅号创作者973524',
|
||||
}
|
||||
}
|
||||
if (message.type === 'OPEN_PLATFORM_TASK') {
|
||||
return {
|
||||
success: true,
|
||||
status: 'filled',
|
||||
tabId: 202,
|
||||
url: 'https://om.qq.com/main/creation/article',
|
||||
message: '企鹅号编辑器已填充',
|
||||
}
|
||||
}
|
||||
if (message.type === 'INSPECT_PLATFORM_TASK') {
|
||||
return {
|
||||
success: true,
|
||||
status: 'published',
|
||||
url: 'https://page.om.qq.com/page/ABCDEF123.html',
|
||||
message: '已确认企鹅号文章公开地址',
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected message: ${message.type}`)
|
||||
}
|
||||
globalThis.chrome.scripting.executeScript = async ({ func, args }) => {
|
||||
scriptExecutionCount += 1
|
||||
qiehaoActionArguments.push(args)
|
||||
const buttons = [
|
||||
{
|
||||
textContent: '定时发布',
|
||||
disabled: false,
|
||||
getAttribute: () => null,
|
||||
click: () => qiehaoClickedActions.push('定时发布'),
|
||||
},
|
||||
{
|
||||
textContent: '发布',
|
||||
disabled: false,
|
||||
getAttribute: () => null,
|
||||
click: () => qiehaoClickedActions.push('发布'),
|
||||
},
|
||||
]
|
||||
globalThis.document = {
|
||||
querySelectorAll: (selector) => {
|
||||
assert.equal(selector, 'button.omui-button.omui-button--primary')
|
||||
return buttons
|
||||
},
|
||||
}
|
||||
try {
|
||||
return [{ result: func(...args) }]
|
||||
}
|
||||
finally {
|
||||
delete globalThis.document
|
||||
}
|
||||
}
|
||||
|
||||
sentMessages.length = 0
|
||||
const qiehaoPublishExecution = await executeClaimedTask({
|
||||
platform: 'qiehao',
|
||||
platformUid: '24973524',
|
||||
title: '企鹅号精确按钮验证',
|
||||
body: '<p>正文</p>',
|
||||
handoff: {
|
||||
publishMode: 'publish',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
assert.equal(qiehaoPublishExecution.report, true)
|
||||
assert.equal(qiehaoPublishExecution.result.status, 'published')
|
||||
assert.equal(qiehaoPublishExecution.result.platformUid, '24973524')
|
||||
assert.deepEqual(qiehaoActionArguments, [[
|
||||
'button.omui-button.omui-button--primary',
|
||||
'发布',
|
||||
]])
|
||||
assert.deepEqual(qiehaoClickedActions, ['发布'])
|
||||
|
||||
globalThis.chrome.runtime.sendMessage = originalQiehaoMessageHandler
|
||||
globalThis.chrome.scripting.executeScript = originalQiehaoScriptHandler
|
||||
|
||||
delete globalThis.chrome
|
||||
|
||||
const markdownTaskContent = toTaskContent({
|
||||
title: '浏览器交接测试',
|
||||
body: '# 标题\n\n- 列表项\n\n',
|
||||
})
|
||||
assert.equal(markdownTaskContent.markdown, '# 标题\n\n- 列表项\n\n')
|
||||
assert.match(markdownTaskContent.html, /<h1>标题<\/h1>/)
|
||||
assert.match(markdownTaskContent.html, /<ul><li>列表项<\/li><\/ul>/)
|
||||
assert.match(markdownTaskContent.html, /<img src="https:\/\/example\.com\/image\.png" alt="配图" \/>/)
|
||||
assert.equal(markdownTaskContent.plainText.includes('#'), false)
|
||||
|
||||
const htmlTaskContent = toTaskContent({
|
||||
title: '富文本交接测试',
|
||||
body: '<p>正文</p><img src="https://example.com/image.png" alt="配图" />',
|
||||
})
|
||||
assert.equal(htmlTaskContent.html, '<p>正文</p><img src="https://example.com/image.png" alt="配图" />')
|
||||
assert.match(htmlTaskContent.plainText, /正文/)
|
||||
assert.equal(htmlTaskContent.plainText.includes('<img'), false)
|
||||
|
||||
const agentSource = await readFile(
|
||||
new URL('../distribution/browser-agent/index.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(agentSource, /chrome\.cookies|Cookie\s*:/)
|
||||
assert.match(agentSource, /x-aitoearn-browser-agent/)
|
||||
assert.match(agentSource, /publication_uncertain/)
|
||||
assert.match(agentSource, /BROWSER_AGENT_PLATFORM_IDS/)
|
||||
|
||||
console.log('Browser agent validation passed')
|
||||
+1832
-1
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user