Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aea2f6810 | |||
| 1a94449988 | |||
| 4f454f0ada | |||
| 026b366f50 | |||
| 61cdcc1afb | |||
| e2244a20bf | |||
| 8612e86ba3 | |||
| 77d44bb52f | |||
| 7988fdab53 | |||
| c98c2d3864 | |||
| f6f2c03790 | |||
| 6f55d2c809 | |||
| 1e48681c86 | |||
| fd192bf9ba | |||
| bd963dea72 | |||
| 3c4fbf6310 |
@@ -1,2 +1,3 @@
|
||||
dist/
|
||||
aitoearn-extension.zip
|
||||
node_modules/
|
||||
|
||||
@@ -5,9 +5,20 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
|
||||
- 从当前网页采集标题、正文和图片。
|
||||
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
|
||||
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
|
||||
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动桥接,不冒充旧官方扩展的完整能力。
|
||||
- 复用浏览器现有登录状态检测内容平台账号。
|
||||
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
|
||||
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
|
||||
- 扩展弹窗从 COSE 适配器动态读取已完成受控填充验证的平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
|
||||
|
||||
## Browser Provider
|
||||
|
||||
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入 CSDN、掘金、简书、知乎、今日头条、百家号、网易号、搜狐号、InfoQ、腾讯云开发者社区和思否:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。CSDN 只使用编辑器自带的签名请求客户端读取创作者资料,思否只从当前页面的 Next.js 会话数据读取 UID;网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID。
|
||||
|
||||
Browser Provider 仅负责账号核对、编辑器填充和草稿/公开文章状态回读,不会自动点击最终公开发布按钮。CSDN 和思否仍保留复制发布稿入口;阿里云开发者社区及其他未通过独立验证的平台继续只支持手动复制,不会伪报“已同步”。
|
||||
|
||||
扩展弹窗还保留公众号的单次人工确认填充入口,但不把它伪装成 Browser Provider。其他旧 COSE 选择器不会出现在扩展平台列表中,也不能通过桥接接口绕过能力白名单;仅有选择器或静态平台配置不代表平台已经接入。
|
||||
|
||||
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。CSDN 只有 `blog.csdn.net/:author/article/details/:id`、掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id` 或 `www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id` 或 `www.infoq.cn/article/:id`、腾讯云只有 `cloud.tencent.com/developer/article/:id`、思否只有 `segmentfault.com/a/:id` 才记为已发布;带草稿标识的编辑器地址,以及网易号、搜狐号或 InfoQ 页面明确提示草稿保存成功时才记为草稿。无法确定时进入“发布结果待确认”,不会伪报成功。
|
||||
|
||||
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
|
||||
|
||||
@@ -15,6 +26,8 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
|
||||
|
||||
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
|
||||
|
||||
当前互动桥接只开放受控白名单:能力查询、抖音账号核对、评论列表、回复列表和单条人工回复。作品 ID、评论 ID、账号 UID、回复内容、游标和数量均由扩展校验,网页不能向扩展传入任意请求地址。发送回复前必须由用户在 AiToEarn 界面逐条确认,扩展会再次核对当前浏览器账号与所选 AiToEarn 账号,写请求不会自动重试,也不支持批量回复、自动回复或私信。扩展优先复用当前 Chrome 中已有的抖音标签页,没有可复用标签页时才创建非激活标签页,并在抖音页面上下文内发起带现有 Cookie 的请求。
|
||||
|
||||
## 上游来源
|
||||
|
||||
多平台检测与编辑器填充逻辑基于 `doocs/cose`,固定版本和许可证见:
|
||||
|
||||
+12
-2
@@ -1,4 +1,6 @@
|
||||
import './distribution/cose/background.js';
|
||||
import './interaction/background.js';
|
||||
import { AUTO_FILL_PLATFORM_IDS } from './distribution/cose/core/platforms/index.js';
|
||||
|
||||
const PREPARE_BATCH_MESSAGE = 'PREPARE_PLATFORM_BATCH';
|
||||
const CAPTURE_URL_MESSAGE = 'CAPTURE_URL';
|
||||
@@ -210,11 +212,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
'getVersion',
|
||||
'listPlatforms',
|
||||
'checkPlatforms',
|
||||
'getPlatformAccount',
|
||||
'captureUrl',
|
||||
'startBatch',
|
||||
'syncToPlatform',
|
||||
'openPlatformTask',
|
||||
'inspectPlatformTask',
|
||||
]);
|
||||
|
||||
async function sendRuntimeMessage(message) {
|
||||
@@ -34,6 +37,14 @@
|
||||
const response = await sendRuntimeMessage({ type: 'CHECK_PLATFORM_STATUS', platforms: selected });
|
||||
return response?.status || {};
|
||||
}
|
||||
case 'getPlatformAccount': {
|
||||
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
|
||||
throw new Error('缺少目标平台');
|
||||
return sendRuntimeMessage({
|
||||
type: 'GET_PLATFORM_ACCOUNT',
|
||||
platformId: payload.platformId,
|
||||
});
|
||||
}
|
||||
case 'captureUrl': {
|
||||
if (typeof payload?.url !== 'string' || !payload.url.trim())
|
||||
throw new Error('缺少要采集的页面地址');
|
||||
@@ -52,6 +63,31 @@
|
||||
content: payload.content,
|
||||
});
|
||||
}
|
||||
case 'openPlatformTask': {
|
||||
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
|
||||
throw new Error('缺少目标平台');
|
||||
if (!payload.content || typeof payload.content !== 'object')
|
||||
throw new Error('缺少分发内容');
|
||||
return sendRuntimeMessage({
|
||||
type: 'OPEN_PLATFORM_TASK',
|
||||
platformId: payload.platformId,
|
||||
expectedPlatformUid: payload.expectedPlatformUid,
|
||||
tabId: payload.tabId,
|
||||
content: payload.content,
|
||||
});
|
||||
}
|
||||
case 'inspectPlatformTask': {
|
||||
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
|
||||
throw new Error('缺少目标平台');
|
||||
if (!Number.isInteger(payload?.tabId) || payload.tabId <= 0)
|
||||
throw new Error('缺少有效的平台编辑器标签页');
|
||||
return sendRuntimeMessage({
|
||||
type: 'INSPECT_PLATFORM_TASK',
|
||||
platformId: payload.platformId,
|
||||
tabId: payload.tabId,
|
||||
expectedPlatformUid: payload.expectedPlatformUid,
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`不支持的分发方法: ${method}`);
|
||||
}
|
||||
|
||||
@@ -44,9 +44,21 @@
|
||||
getVersion: () => request('getVersion'),
|
||||
listPlatforms: () => request('listPlatforms'),
|
||||
checkPlatforms: platformIds => request('checkPlatforms', { platformIds }),
|
||||
getPlatformAccount: platformId => request('getPlatformAccount', { platformId }),
|
||||
captureUrl: url => request('captureUrl', { url }),
|
||||
startBatch: () => request('startBatch'),
|
||||
syncToPlatform: (platformId, content) => request('syncToPlatform', { platformId, content }),
|
||||
openPlatformTask: (platformId, content, expectedPlatformUid, tabId) => request('openPlatformTask', {
|
||||
platformId,
|
||||
content,
|
||||
expectedPlatformUid,
|
||||
tabId,
|
||||
}),
|
||||
inspectPlatformTask: (platformId, tabId, expectedPlatformUid) => request('inspectPlatformTask', {
|
||||
platformId,
|
||||
tabId,
|
||||
expectedPlatformUid,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+329
-276
@@ -1,7 +1,13 @@
|
||||
// 平台配置
|
||||
import { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS } from './core/platforms/index.js'
|
||||
import {
|
||||
AUTO_FILL_PLATFORM_IDS,
|
||||
INSPECT_HANDLERS,
|
||||
PLATFORMS,
|
||||
SYNC_HANDLERS,
|
||||
} from './core/platforms/index.js'
|
||||
import { canReturnExistingPlatformTaskState } from './core/task-state.js'
|
||||
import { qianfanIntercept } from './core/platforms/qianfan.js'
|
||||
import { convertAvatarToBase64 } from './detection/src/utils.js'
|
||||
import { detectSupportedPlatformUser } from './detection/src/supported-platforms.js'
|
||||
// [DISABLED] import { fillAlipayOpenContent } from '@cose/core/src/platforms/alipayopen.js'
|
||||
|
||||
// ===== Offscreen helper =====
|
||||
@@ -122,7 +128,12 @@ function sendOffscreenMessage(msg, timeoutMs = 15000) {
|
||||
* then inject a script that makes the fetch with credentials: 'include'.
|
||||
*/
|
||||
async function tabContextFetch(siteUrl, apiUrl, options = {}) {
|
||||
const { responseType = 'json', timeout = 15000 } = options
|
||||
const {
|
||||
responseType = 'json',
|
||||
timeout = 15000,
|
||||
method = 'GET',
|
||||
body,
|
||||
} = options
|
||||
let createdTabId = null
|
||||
try {
|
||||
const urlObj = new URL(siteUrl)
|
||||
@@ -166,12 +177,18 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
|
||||
// so that credentials: 'include' sends the page's cookies
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: async (fetchUrl, respType) => {
|
||||
func: async (fetchUrl, respType, requestMethod, requestBody) => {
|
||||
try {
|
||||
const headers = {
|
||||
Accept: respType === 'json' ? 'application/json' : 'text/html',
|
||||
}
|
||||
if (requestBody !== undefined) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const resp = await fetch(fetchUrl, {
|
||||
method: 'GET',
|
||||
method: requestMethod,
|
||||
credentials: 'include',
|
||||
headers: { Accept: respType === 'json' ? 'application/json' : 'text/html' },
|
||||
headers,
|
||||
body: requestBody,
|
||||
})
|
||||
const status = resp.status
|
||||
const finalUrl = resp.url
|
||||
@@ -190,7 +207,7 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
|
||||
return { error: e.message }
|
||||
}
|
||||
},
|
||||
args: [apiUrl, responseType],
|
||||
args: [apiUrl, responseType, method, body],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
@@ -224,6 +241,70 @@ async function tabContextFetch(siteUrl, apiUrl, options = {}) {
|
||||
|
||||
globalThis.__coseTabContextFetch = tabContextFetch
|
||||
|
||||
/**
|
||||
* CSDN 的创作者资料接口要求使用编辑器页面自身的签名 HTTP 客户端。
|
||||
* 这里仅在当前 Chrome 的 CSDN 页面上下文中执行该客户端,不导出 Cookie、Token 或 CSRF。
|
||||
*/
|
||||
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 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') {
|
||||
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) {
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.__coseCsdnProfile = getCsdnProfileFromSiteContext
|
||||
|
||||
/**
|
||||
* 51CTO detection via offscreen document (爱贝壳 approach).
|
||||
* Offscreen has document context so DOMParser is available.
|
||||
@@ -438,6 +519,9 @@ const COSE_MESSAGE_TYPES = new Set([
|
||||
'GET_PLATFORMS',
|
||||
'CHECK_PLATFORM_STATUS',
|
||||
'CHECK_PLATFORM_STATUS_PROGRESSIVE',
|
||||
'GET_PLATFORM_ACCOUNT',
|
||||
'OPEN_PLATFORM_TASK',
|
||||
'INSPECT_PLATFORM_TASK',
|
||||
'START_SYNC_BATCH',
|
||||
'SYNC_TO_PLATFORM',
|
||||
'CACHE_USER_INFO',
|
||||
@@ -477,60 +561,77 @@ async function handleMessage(request, sender) {
|
||||
console.log(`[COSE] handleMessage received type: ${request.type}`, request)
|
||||
switch (request.type) {
|
||||
case 'GET_PLATFORMS':
|
||||
return { platforms: PLATFORMS }
|
||||
case 'CHECK_PLATFORM_STATUS':
|
||||
return { status: await checkAllPlatforms(request.platforms || PLATFORMS) }
|
||||
case 'CHECK_PLATFORM_STATUS_PROGRESSIVE':
|
||||
return { platforms: PLATFORMS.filter(platform => platform.autoFillSupported === true) }
|
||||
case 'CHECK_PLATFORM_STATUS': {
|
||||
const platforms = getRequestedAutoFillPlatforms(request.platforms)
|
||||
return { status: await checkAllPlatforms(platforms) }
|
||||
}
|
||||
case 'CHECK_PLATFORM_STATUS_PROGRESSIVE': {
|
||||
// 渐进式检测:每个平台检测完成后立即返回结果
|
||||
checkAllPlatformsProgressive(request.platforms || PLATFORMS, sender.tab?.id)
|
||||
return { started: true, total: (request.platforms || PLATFORMS).length }
|
||||
const platforms = getRequestedAutoFillPlatforms(request.platforms)
|
||||
checkAllPlatformsProgressive(platforms, sender.tab?.id)
|
||||
return { started: true, total: platforms.length }
|
||||
}
|
||||
case 'GET_PLATFORM_ACCOUNT':
|
||||
return await getPlatformAccount(request.platformId)
|
||||
case 'OPEN_PLATFORM_TASK':
|
||||
return await openPlatformTask(
|
||||
request.platformId,
|
||||
request.content,
|
||||
request.expectedPlatformUid,
|
||||
request.tabId
|
||||
)
|
||||
case 'INSPECT_PLATFORM_TASK':
|
||||
return await inspectPlatformTask(
|
||||
request.platformId,
|
||||
request.tabId,
|
||||
request.expectedPlatformUid
|
||||
)
|
||||
case 'START_SYNC_BATCH':
|
||||
// 开始新的同步批次,重置 tab group
|
||||
currentSyncGroupId = null
|
||||
return { success: true }
|
||||
case 'SYNC_TO_PLATFORM':
|
||||
return await syncToPlatform(request.platformId, request.content)
|
||||
return await syncToPlatform(request.platformId, request.content, request.options)
|
||||
case 'CACHE_USER_INFO':
|
||||
// 缓存用户信息
|
||||
if (request.platform === 'xiaohongshu' && request.userInfo) {
|
||||
await chrome.storage.local.set({ xiaohongshu_user: request.userInfo })
|
||||
console.log('[COSE] 小红书用户信息已缓存:', request.userInfo.username)
|
||||
} else if (request.platform === 'alipayopen' && request.userInfo) {
|
||||
await chrome.storage.local.set({ alipayopen_user: request.userInfo })
|
||||
console.log('[COSE] 支付宝用户信息已缓存:', request.userInfo.username)
|
||||
} else if (request.platform === 'huaweicloud' && request.userInfo) {
|
||||
const hwcInfo = { ...request.userInfo }
|
||||
if (hwcInfo.avatar && hwcInfo.avatar.startsWith('http')) {
|
||||
hwcInfo.avatar = await convertAvatarToBase64(
|
||||
hwcInfo.avatar,
|
||||
'https://bbs.huaweicloud.com/'
|
||||
)
|
||||
}
|
||||
await chrome.storage.local.set({ huaweicloud_user: hwcInfo })
|
||||
console.log('[COSE] 华为云用户信息已缓存:', hwcInfo.username)
|
||||
} else if (request.platform === 'huaweidev' && request.userInfo) {
|
||||
const hwdInfo = { ...request.userInfo }
|
||||
if (hwdInfo.avatar && hwdInfo.avatar.startsWith('http')) {
|
||||
hwdInfo.avatar = await convertAvatarToBase64(
|
||||
hwdInfo.avatar,
|
||||
'https://developer.huawei.com/'
|
||||
)
|
||||
}
|
||||
await chrome.storage.local.set({ huaweidev_user: hwdInfo })
|
||||
console.log('[COSE] 华为开发者用户信息已缓存:', hwdInfo.username)
|
||||
if (request.platform !== 'wechat' || !request.userInfo?.platformUid) {
|
||||
return { success: false, error: '该平台未开放受控账号缓存' }
|
||||
}
|
||||
await chrome.storage.local.set({
|
||||
wechat_user: {
|
||||
loggedIn: true,
|
||||
platformUid: String(request.userInfo.platformUid),
|
||||
username: request.userInfo.username || '',
|
||||
avatar: request.userInfo.avatar || '',
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
default:
|
||||
return { error: 'Unknown message type' }
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestedAutoFillPlatforms(requestedPlatforms) {
|
||||
const requestedIds = Array.isArray(requestedPlatforms)
|
||||
? new Set(
|
||||
requestedPlatforms
|
||||
.map(platform => (typeof platform === 'string' ? platform : platform?.id))
|
||||
.filter(platformId => typeof platformId === 'string' && platformId.trim()),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return PLATFORMS.filter(platform => {
|
||||
return platform.autoFillSupported === true
|
||||
&& (!requestedIds || requestedIds.has(platform.id))
|
||||
})
|
||||
}
|
||||
|
||||
// 检查所有平台登录状态
|
||||
async function checkAllPlatforms(platforms) {
|
||||
const status = {}
|
||||
try {
|
||||
// 过滤掉无效的平台配置
|
||||
const validPlatforms = (platforms || []).filter(p => p && p.id)
|
||||
const validPlatforms = getRequestedAutoFillPlatforms(platforms)
|
||||
const results = await Promise.allSettled(
|
||||
validPlatforms.map(async platform => {
|
||||
try {
|
||||
@@ -554,7 +655,7 @@ async function checkAllPlatforms(platforms) {
|
||||
|
||||
// 渐进式检查所有平台登录状态(每个平台完成后立即返回结果)
|
||||
async function checkAllPlatformsProgressive(platforms, tabId) {
|
||||
const validPlatforms = (platforms || []).filter(p => p && p.id)
|
||||
const validPlatforms = getRequestedAutoFillPlatforms(platforms)
|
||||
let completed = 0
|
||||
const total = validPlatforms.length
|
||||
|
||||
@@ -619,14 +720,173 @@ async function checkAllPlatformsProgressive(platforms, tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
import { detectUser } from './detection/index.js'
|
||||
|
||||
// 检查单个平台登录状态
|
||||
async function checkPlatformLogin(platform) {
|
||||
if (!platform || !platform.id) {
|
||||
if (!platform || !AUTO_FILL_PLATFORM_IDS.has(platform.id)) {
|
||||
return { loggedIn: false, error: '无效的平台配置' }
|
||||
}
|
||||
return await detectUser(platform.id)
|
||||
return await detectSupportedPlatformUser(platform.id)
|
||||
}
|
||||
|
||||
async function getPlatformAccount(platformId) {
|
||||
const platform = PLATFORMS.find(item => item?.id === platformId)
|
||||
if (!platform) {
|
||||
return { success: false, error: '暂不支持该平台' }
|
||||
}
|
||||
if (platform.autoFillSupported !== true) {
|
||||
return {
|
||||
success: false,
|
||||
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。',
|
||||
}
|
||||
}
|
||||
|
||||
const account = await checkPlatformLogin(platform)
|
||||
if (!account?.loggedIn) {
|
||||
return {
|
||||
success: false,
|
||||
loggedIn: false,
|
||||
error: account?.error || `请先在当前 Chrome 中登录${platform.title || platform.name}`,
|
||||
}
|
||||
}
|
||||
if (!account.platformUid) {
|
||||
return {
|
||||
success: false,
|
||||
loggedIn: true,
|
||||
error: '已检测到登录状态,但平台没有返回账号 UID',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
loggedIn: true,
|
||||
protocolVersion: 1,
|
||||
platformId,
|
||||
platformUid: String(account.platformUid),
|
||||
displayName: account.username || String(account.platformUid),
|
||||
avatarUrl: account.avatar || undefined,
|
||||
extensionVersion: chrome.runtime.getManifest().version,
|
||||
}
|
||||
}
|
||||
|
||||
function validateExpectedPlatformAccount(account, expectedPlatformUid) {
|
||||
if (expectedPlatformUid && account.platformUid !== expectedPlatformUid) {
|
||||
return {
|
||||
success: false,
|
||||
error: `当前 Chrome 登录账号与任务绑定账号不一致(当前:${account.displayName})`,
|
||||
code: 'ACCOUNT_MISMATCH',
|
||||
account,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function openPlatformTask(platformId, content, expectedPlatformUid, existingTabId) {
|
||||
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
|
||||
|
||||
const inspect = INSPECT_HANDLERS[platformId]
|
||||
let reusableTabId
|
||||
if (inspect && Number.isInteger(existingTabId) && existingTabId > 0) {
|
||||
try {
|
||||
const existingTab = await chrome.tabs.get(existingTabId)
|
||||
const existingState = await inspect(existingTab, { chrome })
|
||||
if (canReturnExistingPlatformTaskState(existingState)) {
|
||||
await chrome.tabs.update(existingTabId, { active: true })
|
||||
return {
|
||||
success: true,
|
||||
...existingState,
|
||||
protocolVersion: 1,
|
||||
platformUid: account.platformUid,
|
||||
displayName: account.displayName,
|
||||
extensionVersion: account.extensionVersion,
|
||||
tabId: existingTabId,
|
||||
}
|
||||
}
|
||||
reusableTabId = existingTabId
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = await syncToPlatform(platformId, content, {
|
||||
active: true,
|
||||
tabId: reusableTabId,
|
||||
})
|
||||
if (!result?.success) return result
|
||||
if (result.tabId) {
|
||||
await chrome.tabs.update(result.tabId, { active: true })
|
||||
}
|
||||
const tab = result.tabId ? await chrome.tabs.get(result.tabId) : null
|
||||
const state = inspect && tab
|
||||
? await inspect(tab, { chrome })
|
||||
: { status: 'filled' }
|
||||
return {
|
||||
...result,
|
||||
...state,
|
||||
protocolVersion: 1,
|
||||
platformUid: account.platformUid,
|
||||
displayName: account.displayName,
|
||||
extensionVersion: account.extensionVersion,
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPlatformTask(platformId, tabId, expectedPlatformUid) {
|
||||
if (!Number.isInteger(tabId) || tabId <= 0) {
|
||||
return { success: false, error: '缺少有效的平台编辑器标签页' }
|
||||
}
|
||||
|
||||
const platform = PLATFORMS.find(item => item?.id === platformId)
|
||||
if (!platform || !platform.publishUrl) {
|
||||
return { success: false, error: '暂不支持该平台' }
|
||||
}
|
||||
if (platform.autoFillSupported !== true) {
|
||||
return {
|
||||
success: false,
|
||||
error: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
|
||||
}
|
||||
}
|
||||
|
||||
const account = await getPlatformAccount(platformId)
|
||||
if (!account.success) return account
|
||||
const mismatch = validateExpectedPlatformAccount(account, expectedPlatformUid)
|
||||
if (mismatch) return mismatch
|
||||
|
||||
const inspect = INSPECT_HANDLERS[platformId]
|
||||
if (!inspect) {
|
||||
return { success: false, error: '该平台暂不支持发布结果核对' }
|
||||
}
|
||||
|
||||
let tab
|
||||
try {
|
||||
tab = await chrome.tabs.get(tabId)
|
||||
} catch {
|
||||
return { success: false, error: '平台编辑器标签页已经关闭,请重新打开并填充' }
|
||||
}
|
||||
const state = await inspect(tab, { chrome })
|
||||
return {
|
||||
success: state.status !== 'failed',
|
||||
...state,
|
||||
protocolVersion: 1,
|
||||
platformUid: account.platformUid,
|
||||
displayName: account.displayName,
|
||||
extensionVersion: account.extensionVersion,
|
||||
tabId,
|
||||
}
|
||||
}
|
||||
async function pasteWithDebugger(tabId) {
|
||||
const debuggee = { tabId }
|
||||
@@ -690,11 +950,17 @@ async function pasteWithDebugger(tabId) {
|
||||
}
|
||||
|
||||
// 同步到平台
|
||||
async function syncToPlatform(platformId, content) {
|
||||
async function syncToPlatform(platformId, content, options = {}) {
|
||||
const platform = PLATFORMS.find(p => p && p.id === platformId)
|
||||
if (!platform || !platform.publishUrl) {
|
||||
return { success: false, message: '暂不支持该平台' }
|
||||
}
|
||||
if (platform.autoFillSupported !== true) {
|
||||
return {
|
||||
success: false,
|
||||
message: platform.unavailableMessage || '该平台尚未完成受控自动填充验证,请使用复制发布稿手动发布。',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let tab
|
||||
@@ -703,10 +969,20 @@ async function syncToPlatform(platformId, content) {
|
||||
const syncHandler = SYNC_HANDLERS[platformId]
|
||||
if (syncHandler) {
|
||||
console.log(`[COSE] 使用 ${platformId} 平台特定同步处理器`)
|
||||
// 创建新标签页(对于微信等需要特殊处理的平台,使用首页)
|
||||
// 创建或复用任务标签页(对于微信等需要特殊处理的平台,使用首页)
|
||||
const initialUrl = platformId === 'wechat' ? 'https://mp.weixin.qq.com/' : platform.publishUrl
|
||||
tab = await chrome.tabs.create({ url: initialUrl, active: false })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
if (Number.isInteger(options.tabId) && options.tabId > 0) {
|
||||
try {
|
||||
tab = await chrome.tabs.update(options.tabId, {
|
||||
url: initialUrl,
|
||||
active: options.active === true,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
if (!tab) {
|
||||
tab = await chrome.tabs.create({ url: initialUrl, active: options.active === true })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
}
|
||||
|
||||
// 调用平台特定处理器
|
||||
const helpers = {
|
||||
@@ -720,90 +996,7 @@ async function syncToPlatform(platformId, content) {
|
||||
|
||||
// ==== 以下是原有的平台特定逻辑(待迁移)====
|
||||
|
||||
if (platformId === 'infoq') {
|
||||
// InfoQ:需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write
|
||||
try {
|
||||
// 调用创建草稿 API
|
||||
const response = await fetch('https://xie.infoq.cn/api/v1/draft/create', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.code === 0 && data.data?.id) {
|
||||
const draftId = data.data.id
|
||||
const targetUrl = `https://xie.infoq.cn/draft/${draftId}`
|
||||
console.log('[COSE] InfoQ 创建草稿成功,ID:', draftId)
|
||||
|
||||
tab = await chrome.tabs.create({ url: targetUrl, active: false })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
await waitForTab(tab.id)
|
||||
} else {
|
||||
console.error('[COSE] InfoQ 创建草稿失败:', data)
|
||||
return { success: false, message: 'InfoQ 创建草稿失败,请确保已登录' }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[COSE] InfoQ API 调用失败:', e)
|
||||
return { success: false, message: 'InfoQ API 调用失败: ' + e.message }
|
||||
}
|
||||
} else if (platformId === 'jianshu') {
|
||||
// 简书:需要先获取文集列表,然后创建新文章
|
||||
try {
|
||||
// 获取用户的文集列表
|
||||
const notebooksResp = await fetch('https://www.jianshu.com/author/notebooks', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
const notebooks = await notebooksResp.json()
|
||||
|
||||
if (!notebooks || notebooks.length === 0) {
|
||||
return { success: false, message: '简书未找到文集,请先创建一个文集' }
|
||||
}
|
||||
|
||||
// 使用第一个文集
|
||||
const notebookId = notebooks[0].id
|
||||
console.log('[COSE] 简书使用文集:', notebooks[0].name, 'ID:', notebookId)
|
||||
|
||||
// 创建新文章
|
||||
const createResp = await fetch('https://www.jianshu.com/author/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notebook_id: String(notebookId),
|
||||
title: content.title || '无标题',
|
||||
at_bottom: false,
|
||||
}),
|
||||
})
|
||||
const noteData = await createResp.json()
|
||||
|
||||
if (noteData && noteData.id) {
|
||||
const noteId = noteData.id
|
||||
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${noteId}`
|
||||
console.log('[COSE] 简书创建文章成功,ID:', noteId)
|
||||
|
||||
tab = await chrome.tabs.create({ url: targetUrl, active: false })
|
||||
await addTabToSyncGroup(tab.id, tab.windowId)
|
||||
await waitForTab(tab.id)
|
||||
} else {
|
||||
console.error('[COSE] 简书创建文章失败:', noteData)
|
||||
return { success: false, message: '简书创建文章失败,请确保已登录' }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[COSE] 简书 API 调用失败:', e)
|
||||
return { success: false, message: '简书 API 调用失败: ' + e.message }
|
||||
}
|
||||
} else if (platformId === 'xiaohongshu') {
|
||||
if (platformId === 'xiaohongshu') {
|
||||
// 小红书:需要先点击"新的创作"按钮,等待编辑器加载后填充
|
||||
console.log('[COSE] 开始处理小红书同步...')
|
||||
|
||||
@@ -2110,68 +2303,6 @@ async function syncToPlatform(platformId, content) {
|
||||
return { success: true, message: '已同步到微博头条', tabId: tab.id }
|
||||
}
|
||||
|
||||
// 阿里云开发者社区:使用 Markdown 编辑器
|
||||
if (platformId === 'aliyun') {
|
||||
// 等待页面完全加载
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// 阿里云使用 Markdown 编辑器
|
||||
const markdownContent = content.markdown || content.body || ''
|
||||
console.log('[COSE] 阿里云开发者社区 Markdown 内容长度:', markdownContent?.length || 0)
|
||||
|
||||
// 填充标题和内容
|
||||
const fillResult = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (title, markdown) => {
|
||||
// 填充标题
|
||||
const titleInput = document.querySelector('input[placeholder*="标题"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 使用 native setter 来绕过 React 的受控组件
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
nativeSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 阿里云开发者社区标题填充成功')
|
||||
}
|
||||
|
||||
// 填充内容 - 阿里云使用 textarea 作为 Markdown 编辑器
|
||||
const contentTextarea =
|
||||
document.querySelector('textarea[class*="editor"]') ||
|
||||
document.querySelector('.markdown-editor textarea') ||
|
||||
document.querySelector('textarea:not([placeholder*="标题"])')
|
||||
|
||||
if (contentTextarea && markdown) {
|
||||
contentTextarea.focus()
|
||||
// 使用 native setter 来绕过 React 的受控组件
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
nativeSetter.call(contentTextarea, markdown)
|
||||
contentTextarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
contentTextarea.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 阿里云开发者社区内容填充成功')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Editor not found' }
|
||||
},
|
||||
args: [content.title, markdownContent],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
console.log('[COSE] 阿里云开发者社区填充结果:', fillResult)
|
||||
|
||||
// 等待内容注入完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
return { success: true, message: '已同步到阿里云开发者社区', tabId: tab.id }
|
||||
}
|
||||
|
||||
// 火山引擎开发者社区:使用 ByteMD 编辑器(基于 CodeMirror)
|
||||
if (platformId === 'volcengine') {
|
||||
// 等待页面完全加载
|
||||
@@ -3464,41 +3595,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 编辑器(如果当前不是)
|
||||
@@ -3695,49 +3791,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 开始同步...')
|
||||
|
||||
@@ -1,53 +1,329 @@
|
||||
// 阿里云开发者社区平台配置
|
||||
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 normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
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 sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
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) {
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(normalizeText(markdownContent).length * 0.1)), 120)
|
||||
return normalizeText(value).length >= minimumLength
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const textLength = normalizeText(editor?.innerText || editor?.textContent).length
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(normalizeText(plainText).length * 0.1)), 120)
|
||||
return textLength + imageCount * 12 >= minimumLength
|
||||
}
|
||||
|
||||
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)
|
||||
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([
|
||||
'.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,303 @@ 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, '')
|
||||
|
||||
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 expectedTextLength = normalizeText(plainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
return normalizeText(text).length + imageCount * 12 >= minimumLength
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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,261 @@ 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, '')
|
||||
|
||||
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 expectedLength = normalizeText(contentToFill).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedLength * 0.1)), 120)
|
||||
const actualLength = normalizeText(editor?.value || editor?.innerText || editor?.textContent).length
|
||||
return actualLength >= minimumLength
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
// 填充标题(使用注入的 window.waitFor)
|
||||
const titleInput = await window.waitFor('.article-bar__title input, input[placeholder*="标题"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
titleInput.value = title
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const titleInput = await waitForElement([
|
||||
'.article-bar__title input',
|
||||
'input[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
'input.title-input',
|
||||
])
|
||||
if (title && !titleInput) return { success: false, error: '未找到 CSDN 标题输入框' }
|
||||
if (titleInput && title) setInputValue(titleInput, title)
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
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.CodeMirror && typeof editor.CodeMirror.setValue === 'function') {
|
||||
editor.CodeMirror.setValue(contentToFill)
|
||||
editor.CodeMirror.focus?.()
|
||||
await sleep(500)
|
||||
if (hasExpectedContent(editor.CodeMirror.getWrapperElement?.() || editor)) {
|
||||
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' }
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// CSDN 使用 contenteditable 的 PRE 元素
|
||||
const editor = document.querySelector(
|
||||
'.editor__inner[contenteditable="true"], [contenteditable="true"].markdown-highlighting'
|
||||
)
|
||||
|
||||
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' }
|
||||
}
|
||||
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,28 +1,64 @@
|
||||
// 平台配置汇总
|
||||
// 从 @cose/detection 导入登录检测配置
|
||||
import { LOGIN_CHECK_CONFIG } from '../../detection/index.js'
|
||||
import { LOGIN_CHECK_CONFIG } from '../../detection/src/configs.js'
|
||||
|
||||
// 平台元数据和同步函数从各平台文件导入
|
||||
import { CSDNPlatform, syncCSDNContent } from './csdn.js'
|
||||
import { JuejinPlatform, syncJuejinContent } from './juejin.js'
|
||||
import { CSDNPlatform, inspectCsdnTask, syncCsdnContent } from './csdn.js'
|
||||
import { inspectJuejinTaskState, JuejinPlatform, syncJuejinContent } from './juejin.js'
|
||||
import { WechatPlatform, syncWechatContent } from './wechat.js'
|
||||
import { ZhihuPlatform, syncZhihuContent } from './zhihu.js'
|
||||
import { ToutiaoPlatform, syncToutiaoContent } from './toutiao.js'
|
||||
import { SegmentFaultPlatform } from './segmentfault.js'
|
||||
import {
|
||||
inspectZhihuTask,
|
||||
ZhihuPlatform,
|
||||
syncZhihuContent,
|
||||
} from './zhihu.js'
|
||||
import {
|
||||
inspectToutiaoTask,
|
||||
ToutiaoPlatform,
|
||||
syncToutiaoContent,
|
||||
} from './toutiao.js'
|
||||
import {
|
||||
inspectSegmentFaultTask,
|
||||
SegmentFaultPlatform,
|
||||
syncSegmentFaultContent,
|
||||
} from './segmentfault.js'
|
||||
import { CnblogsPlatform } from './cnblogs.js'
|
||||
import { OSChinaPlatform } from './oschina.js'
|
||||
import { CTO51Platform } from './cto51.js'
|
||||
import { InfoQPlatform } from './infoq.js'
|
||||
import { JianshuPlatform } from './jianshu.js'
|
||||
import { BaijiahaoPlatform } from './baijiahao.js'
|
||||
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
|
||||
import { TencentCloudPlatform } from './tencentcloud.js'
|
||||
import {
|
||||
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 +71,26 @@ 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',
|
||||
])
|
||||
|
||||
const AUTO_FILL_UNAVAILABLE_MESSAGE = '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。'
|
||||
|
||||
// 合并平台配置
|
||||
const PLATFORMS = [
|
||||
const PLATFORM_DEFINITIONS = [
|
||||
CSDNPlatform,
|
||||
JuejinPlatform,
|
||||
WechatPlatform,
|
||||
@@ -70,53 +124,56 @@ 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,
|
||||
}
|
||||
|
||||
const INSPECT_HANDLERS = {
|
||||
csdn: inspectCsdnTask,
|
||||
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
|
||||
jianshu: inspectJianshuTask,
|
||||
zhihu: inspectZhihuTask,
|
||||
toutiao: inspectToutiaoTask,
|
||||
baijiahao: inspectBaijiahaoTask,
|
||||
wangyi: inspectWangyihaoTask,
|
||||
sohu: inspectSohuTask,
|
||||
infoq: inspectInfoQTask,
|
||||
tencentcloud: inspectTencentCloudTask,
|
||||
aliyun: inspectAliyunTask,
|
||||
segmentfault: inspectSegmentFaultTask,
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
|
||||
export {
|
||||
AUTO_FILL_PLATFORM_IDS,
|
||||
INSPECT_HANDLERS,
|
||||
PLATFORMS,
|
||||
LOGIN_CHECK_CONFIG,
|
||||
SYNC_HANDLERS,
|
||||
}
|
||||
|
||||
@@ -1,58 +1,404 @@
|
||||
// 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, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
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 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 expectedTextLength = normalizeText(plainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
const textLength = normalizeText(editor?.innerText || editor?.textContent).length
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
return textLength + imageCount * 12 >= minimumLength
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.gk-editor [contenteditable="true"]',
|
||||
'.gk-editor .ProseMirror',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 简书平台配置
|
||||
const JianshuPlatform = {
|
||||
id: 'jianshu',
|
||||
@@ -9,51 +11,207 @@ 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 || ''
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await window.waitFor('input._24i7u, input[class*="title"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
const inputSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set
|
||||
inputSetter?.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' }),
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const editor = document.querySelector('#arthur-editor')
|
||||
|| document.querySelector('textarea._3swFR')
|
||||
if (!editor) return { success: false, error: 'Editor not found' }
|
||||
|
||||
editor.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value',
|
||||
)?.set
|
||||
textareaSetter?.call(editor, contentToFill)
|
||||
editor.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' }),
|
||||
)
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
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)
|
||||
await injectUtils(chrome, 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,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,55 @@ const JuejinPlatform = {
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
function inspectJuejinTaskState(rawUrl) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取掘金标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || !['juejin.cn', 'www.juejin.cn'].includes(url.hostname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开掘金,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
const published = url.pathname.match(/^\/post\/([^/]+)\/?$/)
|
||||
if (published?.[1]) {
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认掘金文章公开地址',
|
||||
}
|
||||
}
|
||||
|
||||
const draft = url.pathname.match(/^\/editor\/drafts\/([^/]+)\/?$/)
|
||||
if (draft?.[1] && draft[1] !== 'new') {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draft[1],
|
||||
message: '已确认掘金草稿地址',
|
||||
}
|
||||
}
|
||||
if (draft?.[1] === 'new') {
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前掘金页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
// 掘金内容填充函数(在页面主世界中执行)
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
function fillJuejinContent(title, markdown, body) {
|
||||
@@ -86,4 +135,4 @@ async function syncJuejinContent(tab, content, helpers) {
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { JuejinPlatform, fillJuejinContent, syncJuejinContent }
|
||||
export { JuejinPlatform, fillJuejinContent, inspectJuejinTaskState, syncJuejinContent }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 思否平台配置
|
||||
const SegmentFaultPlatform = {
|
||||
id: 'segmentfault',
|
||||
name: 'SegmentFault',
|
||||
@@ -9,42 +8,271 @@ 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 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 hasExpectedContent(editor) {
|
||||
const expectedLength = normalizeText(contentToFill).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedLength * 0.1)), 120)
|
||||
const actualLength = normalizeText(editor?.value || editor?.innerText || editor?.textContent).length
|
||||
return actualLength >= minimumLength
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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.getWrapperElement?.() || editor)) {
|
||||
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: '思否未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
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,316 @@
|
||||
// 搜狐号平台配置
|
||||
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, '')
|
||||
|
||||
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, expectedPlainText) {
|
||||
const expectedTextLength = normalizeText(expectedPlainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
return normalizeText(text).length + imageCount * 12 >= minimumLength
|
||||
}
|
||||
|
||||
function 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)
|
||||
|
||||
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, plainText)) {
|
||||
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, plainText)) {
|
||||
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, plainText)) {
|
||||
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,273 @@ 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 normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保编辑器处于 Markdown 模式
|
||||
* 1. 检查是否有"切换 MD 编辑器"按钮
|
||||
* 2. 如果有,点击切换到 MD 编辑器
|
||||
* 3. 如果没有,说明已经是 MD 编辑器
|
||||
* @returns {Promise<boolean>} 是否成功进入 MD 编辑器模式
|
||||
*/
|
||||
async function ensureMarkdownEditor() {
|
||||
// 等待页面加载完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const switchBtn = findSwitchToMDButton()
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
if (switchBtn) {
|
||||
// 找到了"切换 MD 编辑器"按钮,说明当前是富文本编辑器,需要切换
|
||||
console.log('[COSE] TencentCloud 检测到富文本编辑器,正在切换到 MD 编辑器...')
|
||||
switchBtn.click()
|
||||
function parseTencentCloudPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
// 等待切换完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://cloud.tencent.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'cloud.tencent.com') return null
|
||||
|
||||
// 验证切换是否成功:检查 CodeMirror 是否存在
|
||||
const cm = document.querySelector('.CodeMirror')
|
||||
if (cm && cm.CodeMirror) {
|
||||
console.log('[COSE] TencentCloud 成功切换到 MD 编辑器')
|
||||
return true
|
||||
const match = url.pathname.match(/^\/developer\/article\/(\d+)\/?$/)
|
||||
if (!match?.[1]) return null
|
||||
|
||||
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, plainText) {
|
||||
const contentToFill = markdown || plainText || ''
|
||||
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 findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
// 第二步:获取 CodeMirror 实例
|
||||
const codeMirror = await getCodeMirror(3000)
|
||||
if (!codeMirror) {
|
||||
console.error('[COSE] TencentCloud 错误:CodeMirror 未加载,请刷新页面后重试')
|
||||
return
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// 第三步:填充标题
|
||||
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 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 }))
|
||||
}
|
||||
|
||||
// 第四步:填充内容到 CodeMirror
|
||||
codeMirror.setValue(contentToFill)
|
||||
console.log('[COSE] TencentCloud 内容填充成功')
|
||||
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)
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'])
|
||||
const editor = codeMirror?.CodeMirror || getCodeMirror()
|
||||
if (editor && typeof editor.setValue === 'function') {
|
||||
editor.focus()
|
||||
editor.setValue(contentToFill)
|
||||
await sleep(250)
|
||||
if (normalizeText(editor.getValue?.()) === normalizeText(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'textarea[placeholder*="正文"]',
|
||||
'.markdown-editor textarea',
|
||||
'.editor-container textarea',
|
||||
], 4000)
|
||||
if (!textarea) return { success: false, error: '未找到腾讯云开发者社区 Markdown 编辑器' }
|
||||
|
||||
setInputValue(textarea, contentToFill)
|
||||
await sleep(250)
|
||||
if (normalizeText(textarea.value) !== normalizeText(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.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,434 @@ 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, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const el = predicate()
|
||||
if (el) return resolve(el)
|
||||
const existing = findFirst(selectors)
|
||||
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)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(predicate())
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
async function fillContent() {
|
||||
// 填充标题 - 头条使用 textarea
|
||||
const titleInput = await waitForElement(() =>
|
||||
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 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] 头条未找到标题输入框')
|
||||
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)
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
])
|
||||
if (!editor) return { success: false, error: '未找到今日头条正文编辑器' }
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const html = renderToutiaoMarkdown(contentToFill)
|
||||
const plainText = renderToutiaoPlainText(contentToFill)
|
||||
dispatchHtmlPaste(editor, html, plainText)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
const expectedTextLength = plainText.length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
let editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
let imageCount = editor.querySelectorAll('img').length
|
||||
|
||||
if (editorTextLength + imageCount * 12 < minimumLength && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, html)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
|
||||
await sleep(500)
|
||||
editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
imageCount = editor.querySelectorAll('img').length
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
if (editorTextLength + imageCount * 12 < minimumLength) {
|
||||
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 result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillToutiaoContentInPage,
|
||||
args: [content.title, content.body || content.markdown || ''],
|
||||
func: fillToutiaoContent,
|
||||
args: [content.title, content.markdown, content.body],
|
||||
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,304 @@
|
||||
// 网易号平台配置
|
||||
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, '')
|
||||
|
||||
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, expectedPlainText) {
|
||||
const expectedTextLength = normalizeText(expectedPlainText).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
const editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
const imageCount = editor.querySelectorAll('img').length
|
||||
return editorTextLength + imageCount * 12 >= minimumLength
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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, plainText) && 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, plainText)) {
|
||||
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,284 @@ 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 => (value || '').replace(/\s+/g, '')
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const el = predicate()
|
||||
if (el) return resolve(el)
|
||||
const existing = findFirst(selectors)
|
||||
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)
|
||||
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))
|
||||
}, 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 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"]',
|
||||
])
|
||||
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 expectedTextLength = normalizeText(contentToFill
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
|
||||
.replace(/[\[\]#*_>`~()]/g, ''))
|
||||
.length
|
||||
const editorTextLength = normalizeText(editor.innerText || editor.textContent).length
|
||||
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
|
||||
const imageEquivalentLength = editor.querySelectorAll('img').length * 12
|
||||
if (editorTextLength + imageEquivalentLength < minimumLength) {
|
||||
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,11 @@
|
||||
const RETURNABLE_PLATFORM_TASK_STATUSES = new Set([
|
||||
'filled',
|
||||
'draft_saved',
|
||||
'published',
|
||||
])
|
||||
|
||||
function canReturnExistingPlatformTaskState(state) {
|
||||
return Boolean(state?.url && RETURNABLE_PLATFORM_TASK_STATUSES.has(state.status))
|
||||
}
|
||||
|
||||
export { canReturnExistingPlatformTaskState }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const JuejinLoginConfig = {
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.err_no === 0 && response?.data?.user_id,
|
||||
getUserInfo: response => ({
|
||||
platformUid: response?.data?.user_id,
|
||||
username: response?.data?.user_name,
|
||||
avatar: response?.data?.avatar_large,
|
||||
}),
|
||||
@@ -26,31 +27,70 @@ export const ZhihuLoginConfig = {
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.id,
|
||||
getUserInfo: response => ({
|
||||
platformUid: response?.id,
|
||||
username: response?.name,
|
||||
avatar: response?.avatar_url,
|
||||
}),
|
||||
}
|
||||
|
||||
// 头条号
|
||||
export function parseToutiaoAccount(response) {
|
||||
const data = response?.data && typeof response.data === 'object' ? response.data : response
|
||||
const user = data?.user && typeof data.user === 'object' ? data.user : undefined
|
||||
const platformUid = [
|
||||
data?.user_id,
|
||||
data?.uid,
|
||||
data?.creator_id,
|
||||
data?.account_id,
|
||||
data?.id,
|
||||
user?.user_id,
|
||||
user?.uid,
|
||||
user?.id,
|
||||
].find(value => typeof value === 'string' || typeof value === 'number')
|
||||
|
||||
return {
|
||||
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
|
||||
username: data?.name || data?.nickname || user?.name || user?.nickname,
|
||||
avatar: data?.avatar_url || data?.avatar || user?.avatar_url || user?.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
export const ToutiaoLoginConfig = {
|
||||
api: 'https://mp.toutiao.com/mp/agw/creator_center/user_info?app_id=1231',
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.code === 0 && response?.name,
|
||||
getUserInfo: response => ({
|
||||
username: response?.name,
|
||||
avatar: response?.avatar_url,
|
||||
}),
|
||||
checkLogin: response => response?.code === 0 && Boolean(parseToutiaoAccount(response).platformUid),
|
||||
getUserInfo: parseToutiaoAccount,
|
||||
}
|
||||
|
||||
// 百家号
|
||||
export function parseBaijiahaoAccount(response) {
|
||||
const data = response?.data && typeof response.data === 'object' ? response.data : response
|
||||
const user = data?.user && typeof data.user === 'object' ? data.user : undefined
|
||||
const platformUid = [
|
||||
user?.user_id,
|
||||
user?.userId,
|
||||
user?.userid,
|
||||
user?.uid,
|
||||
user?.uk,
|
||||
user?.id,
|
||||
data?.user_id,
|
||||
data?.uid,
|
||||
data?.uk,
|
||||
data?.id,
|
||||
].find(value => typeof value === 'string' || typeof value === 'number')
|
||||
|
||||
return {
|
||||
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
|
||||
username: user?.name || user?.nickname || data?.name,
|
||||
avatar: user?.avatar || user?.avatar_url || data?.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
export const BaijiahaoLoginConfig = {
|
||||
api: 'https://baijiahao.baidu.com/builder/app/appinfo',
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.errno === 0 && response?.data?.user?.name,
|
||||
getUserInfo: response => ({
|
||||
username: response?.data?.user?.name,
|
||||
avatar: response?.data?.user?.avatar,
|
||||
}),
|
||||
checkLogin: response => response?.errno === 0 && Boolean(parseBaijiahaoAccount(response).platformUid),
|
||||
getUserInfo: parseBaijiahaoAccount,
|
||||
}
|
||||
|
||||
// 抖音
|
||||
|
||||
@@ -53,7 +53,7 @@ const PLATFORM_DETECTORS = {
|
||||
modelscope: detectModelScopeUser,
|
||||
volcengine: detectVolcengineUser,
|
||||
cnblogs: detectCnblogsUser,
|
||||
wangyihao: detectWangyihaoUser,
|
||||
wangyi: detectWangyihaoUser,
|
||||
douban: detectDoubanUser,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
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?.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,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,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,83 @@
|
||||
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(
|
||||
/"(?:currentUser|userInfo|creatorInfo)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,4000}\})/,
|
||||
)
|
||||
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('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,114 @@
|
||||
import {
|
||||
BaijiahaoLoginConfig,
|
||||
JuejinLoginConfig,
|
||||
ToutiaoLoginConfig,
|
||||
ZhihuLoginConfig,
|
||||
} from './configs.js'
|
||||
import { detectCsdnUser } from './platforms/csdn.js'
|
||||
import { detectAliyunUser } from './platforms/aliyun.js'
|
||||
import { detectInfoQUser } from './platforms/infoq.js'
|
||||
import { detectJianshuUser } from './platforms/jianshu.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,
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,51 @@
|
||||
(function bridgeAiToEarnInteraction() {
|
||||
const requestSource = 'aitoearn-interaction-web';
|
||||
const responseSource = 'aitoearn-interaction-extension';
|
||||
const allowedMethods = new Set([
|
||||
'getCapabilities',
|
||||
'getDouyinAccount',
|
||||
'listDouyinComments',
|
||||
'listDouyinReplies',
|
||||
'replyDouyinComment',
|
||||
]);
|
||||
|
||||
async function handle(method, payload) {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'AITO_EARN_INTERACTION',
|
||||
method,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!response?.success) {
|
||||
const error = new Error(response?.error?.message || 'AiToEarn 互动扩展请求失败');
|
||||
error.code = response?.error?.code || 'INTERACTION_REQUEST_FAILED';
|
||||
throw error;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (
|
||||
event.source !== window
|
||||
|| event.origin !== window.location.origin
|
||||
|| event.data?.source !== requestSource
|
||||
) return;
|
||||
|
||||
const { requestId, method, payload } = event.data;
|
||||
if (!requestId || !allowedMethods.has(method)) return;
|
||||
|
||||
try {
|
||||
const result = await handle(method, payload);
|
||||
window.postMessage({ source: responseSource, requestId, result }, window.location.origin);
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
source: responseSource,
|
||||
requestId,
|
||||
error: {
|
||||
code: error?.code || 'INTERACTION_REQUEST_FAILED',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}, window.location.origin);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
(function installAiToEarnInteractionBridge() {
|
||||
if (window.AIToEarnInteraction) return;
|
||||
|
||||
const requestSource = 'aitoearn-interaction-web';
|
||||
const responseSource = 'aitoearn-interaction-extension';
|
||||
const pending = new Map();
|
||||
const defaultTimeoutMs = 60000;
|
||||
const methodTimeoutMs = Object.freeze({
|
||||
replyDouyinComment: 120000,
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (
|
||||
event.source !== window
|
||||
|| event.origin !== window.location.origin
|
||||
|| event.data?.source !== responseSource
|
||||
) return;
|
||||
|
||||
const entry = pending.get(event.data.requestId);
|
||||
if (!entry) return;
|
||||
pending.delete(event.data.requestId);
|
||||
|
||||
if (event.data.error) {
|
||||
const error = new Error(event.data.error.message || 'AiToEarn 互动扩展请求失败');
|
||||
error.code = event.data.error.code || 'INTERACTION_REQUEST_FAILED';
|
||||
entry.reject(error);
|
||||
return;
|
||||
}
|
||||
entry.resolve(event.data.result);
|
||||
});
|
||||
|
||||
function request(method, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const timeoutMs = methodTimeoutMs[method] || defaultTimeoutMs;
|
||||
const timeout = window.setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
const error = new Error('AiToEarn 互动扩展响应超时');
|
||||
error.code = 'INTERACTION_RESPONSE_TIMEOUT';
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(requestId, {
|
||||
resolve(value) {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
reject(error) {
|
||||
window.clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
window.postMessage({ source: requestSource, requestId, method, payload }, window.location.origin);
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'AIToEarnInteraction', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: {
|
||||
getCapabilities: () => request('getCapabilities'),
|
||||
getDouyinAccount: () => request('getDouyinAccount'),
|
||||
listDouyinComments: payload => request('listDouyinComments', payload),
|
||||
listDouyinReplies: payload => request('listDouyinReplies', payload),
|
||||
replyDouyinComment: payload => request('replyDouyinComment', payload),
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('aitoearn:interaction-ready'));
|
||||
})();
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
doesDouyinAccountMatch,
|
||||
executeDouyinCommentRequest,
|
||||
executeDouyinReplyRequest,
|
||||
executeDouyinSessionRequest,
|
||||
InteractionValidationError,
|
||||
normalizeDouyinRequest,
|
||||
} from './douyin.js';
|
||||
import { createInteractionCapabilities } from './capabilities.js';
|
||||
|
||||
const MESSAGE_TYPE = 'AITO_EARN_INTERACTION';
|
||||
const ALLOWED_APP_ORIGINS = new Set([
|
||||
'http://localhost:6061',
|
||||
'http://127.0.0.1:6061',
|
||||
'https://wx.frp.it1024.cc',
|
||||
]);
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function createError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isAllowedSender(sender) {
|
||||
const senderUrl = sender?.url || sender?.tab?.url;
|
||||
if (!senderUrl) return false;
|
||||
try {
|
||||
return ALLOWED_APP_ORIGINS.has(new URL(senderUrl).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTabReady(tabId) {
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (tab.status === 'complete') return tab;
|
||||
await sleep(250);
|
||||
}
|
||||
throw createError('DOUYIN_PAGE_TIMEOUT', '抖音页面加载超时');
|
||||
}
|
||||
|
||||
async function acquireDouyinTab(workId) {
|
||||
const workUrl = `https://www.douyin.com/video/${workId}`;
|
||||
const tabs = await chrome.tabs.query({ url: ['https://www.douyin.com/*'] });
|
||||
let tab = tabs.find(candidate => candidate.id && candidate.url?.startsWith(workUrl));
|
||||
if (!tab) tab = tabs.find(candidate => candidate.id && !candidate.discarded);
|
||||
|
||||
const created = !tab;
|
||||
if (!tab) tab = await chrome.tabs.create({ url: workUrl, active: false });
|
||||
if (!tab.id) throw createError('DOUYIN_TAB_UNAVAILABLE', '无法创建或复用抖音标签页');
|
||||
|
||||
await waitForTabReady(tab.id);
|
||||
return { tabId: tab.id, created };
|
||||
}
|
||||
|
||||
async function acquireDouyinCreatorTab(allowCreate) {
|
||||
const tabs = await chrome.tabs.query({ url: ['https://creator.douyin.com/*'] });
|
||||
let tab = tabs.find(candidate => candidate.id && !candidate.discarded);
|
||||
const created = !tab;
|
||||
if (!tab && !allowCreate) {
|
||||
throw createError(
|
||||
'DOUYIN_CREATOR_TAB_REQUIRED',
|
||||
'请先在当前浏览器打开并登录抖音创作者中心,再刷新互动管理',
|
||||
);
|
||||
}
|
||||
if (!tab) tab = await chrome.tabs.create({ url: 'https://creator.douyin.com/', active: false });
|
||||
if (!tab.id) throw createError('DOUYIN_CREATOR_TAB_UNAVAILABLE', '无法创建或复用抖音创作者中心标签页');
|
||||
|
||||
await waitForTabReady(tab.id);
|
||||
return { tabId: tab.id, created };
|
||||
}
|
||||
|
||||
async function readDouyinSession(includeCsrf = false, allowCreate = false) {
|
||||
const tab = await acquireDouyinCreatorTab(allowCreate);
|
||||
let keepTabOpen = false;
|
||||
try {
|
||||
const execution = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.tabId },
|
||||
world: 'MAIN',
|
||||
func: executeDouyinSessionRequest,
|
||||
args: [{ includeCsrf }],
|
||||
});
|
||||
const response = execution?.[0]?.result;
|
||||
if (!response?.ok) {
|
||||
const code = response?.error?.code || 'DOUYIN_ACCOUNT_REQUEST_FAILED';
|
||||
const message = response?.error?.message || '抖音账号读取失败';
|
||||
if (code === 'DOUYIN_LOGIN_REQUIRED') {
|
||||
keepTabOpen = true;
|
||||
await chrome.tabs.update(tab.tabId, { active: true });
|
||||
}
|
||||
throw createError(code, message);
|
||||
}
|
||||
return response.result;
|
||||
} finally {
|
||||
if (tab.created && !keepTabOpen) {
|
||||
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listDouyinComments(method, payload) {
|
||||
const request = normalizeDouyinRequest(method, payload);
|
||||
const tab = await acquireDouyinTab(request.workId);
|
||||
let keepTabOpen = false;
|
||||
|
||||
try {
|
||||
const execution = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.tabId },
|
||||
world: 'MAIN',
|
||||
func: executeDouyinCommentRequest,
|
||||
args: [{
|
||||
...request,
|
||||
kind: method === 'listDouyinReplies' ? 'replies' : 'comments',
|
||||
}],
|
||||
});
|
||||
const response = execution?.[0]?.result;
|
||||
if (!response?.ok) {
|
||||
const code = response?.error?.code || 'DOUYIN_REQUEST_FAILED';
|
||||
const message = response?.error?.message || '抖音评论读取失败';
|
||||
if (code === 'DOUYIN_LOGIN_REQUIRED') {
|
||||
keepTabOpen = true;
|
||||
await chrome.tabs.update(tab.tabId, { active: true });
|
||||
}
|
||||
throw createError(code, message);
|
||||
}
|
||||
|
||||
return {
|
||||
...response.result,
|
||||
reusedTab: !tab.created,
|
||||
};
|
||||
} finally {
|
||||
if (tab.created && !keepTabOpen) {
|
||||
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function replyDouyinComment(payload) {
|
||||
const request = normalizeDouyinRequest('replyDouyinComment', payload);
|
||||
const session = await readDouyinSession(true, true);
|
||||
if (!doesDouyinAccountMatch(request.expectedAccountUid, session.account)) {
|
||||
throw createError(
|
||||
'DOUYIN_ACCOUNT_MISMATCH',
|
||||
`当前浏览器登录的是“${session.account.nickname || session.account.uid}”,与所选 AiToEarn 账号不一致`,
|
||||
);
|
||||
}
|
||||
|
||||
const tab = await acquireDouyinTab(request.workId);
|
||||
let keepTabOpen = false;
|
||||
try {
|
||||
const execution = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.tabId },
|
||||
world: 'MAIN',
|
||||
func: executeDouyinReplyRequest,
|
||||
args: [{ ...request, csrfToken: session.csrfToken }],
|
||||
});
|
||||
const response = execution?.[0]?.result;
|
||||
if (!response?.ok) {
|
||||
const code = response?.error?.code || 'DOUYIN_REPLY_REQUEST_FAILED';
|
||||
const message = response?.error?.message || '抖音评论回复失败';
|
||||
if (code === 'DOUYIN_LOGIN_REQUIRED' || code === 'DOUYIN_RISK_CONTROL') {
|
||||
keepTabOpen = true;
|
||||
await chrome.tabs.update(tab.tabId, { active: true });
|
||||
}
|
||||
throw createError(code, message);
|
||||
}
|
||||
|
||||
return {
|
||||
...response.result,
|
||||
account: session.account,
|
||||
reusedTab: !tab.created,
|
||||
};
|
||||
} finally {
|
||||
if (tab.created && !keepTabOpen) {
|
||||
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCapabilities() {
|
||||
return createInteractionCapabilities(chrome.runtime.getManifest().version);
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request?.type !== MESSAGE_TYPE) return false;
|
||||
|
||||
if (!isAllowedSender(sender)) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: { code: 'INTERACTION_ORIGIN_DENIED', message: '当前页面无权调用互动扩展' },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const method = request.method;
|
||||
if (![
|
||||
'getCapabilities',
|
||||
'getDouyinAccount',
|
||||
'listDouyinComments',
|
||||
'listDouyinReplies',
|
||||
'replyDouyinComment',
|
||||
].includes(method)) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: { code: 'INTERACTION_METHOD_DENIED', message: '不支持的互动扩展方法' },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
Promise.resolve(
|
||||
method === 'getCapabilities'
|
||||
? getCapabilities()
|
||||
: method === 'getDouyinAccount'
|
||||
? readDouyinSession(false, false).then(session => session.account)
|
||||
: method === 'replyDouyinComment'
|
||||
? replyDouyinComment(request.payload)
|
||||
: listDouyinComments(method, request.payload),
|
||||
)
|
||||
.then(result => sendResponse({ success: true, result }))
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: {
|
||||
code: error?.code || (error instanceof InteractionValidationError
|
||||
? 'INVALID_INTERACTION_PAYLOAD'
|
||||
: 'INTERACTION_REQUEST_FAILED'),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export function createInteractionCapabilities(version) {
|
||||
return {
|
||||
version,
|
||||
source: 'aitoearn-interaction',
|
||||
platforms: {
|
||||
douyin: {
|
||||
comments: {
|
||||
list: true,
|
||||
replies: true,
|
||||
create: false,
|
||||
manualReply: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
const ID_PATTERN = /^\d{1,32}$/;
|
||||
const CURSOR_PATTERN = /^\d{1,16}$/;
|
||||
const DEFAULT_COUNT = 20;
|
||||
const MAX_COUNT = 20;
|
||||
const MAX_REPLY_LENGTH = 500;
|
||||
|
||||
export class InteractionValidationError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = 'InteractionValidationError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeId(value, fieldName) {
|
||||
const id = typeof value === 'number' ? String(value) : String(value || '').trim();
|
||||
if (!ID_PATTERN.test(id)) {
|
||||
throw new InteractionValidationError('INVALID_DOUYIN_ID', `${fieldName} 必须是纯数字 ID`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeCursor(value) {
|
||||
if (value === undefined || value === null || value === '') return '0';
|
||||
const cursor = typeof value === 'number' ? String(value) : String(value).trim();
|
||||
if (!CURSOR_PATTERN.test(cursor)) {
|
||||
throw new InteractionValidationError('INVALID_DOUYIN_CURSOR', '分页游标必须是非负整数');
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function normalizeCount(value) {
|
||||
if (value === undefined || value === null || value === '') return DEFAULT_COUNT;
|
||||
const count = Number(value);
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new InteractionValidationError('INVALID_DOUYIN_COUNT', '每页数量必须是正整数');
|
||||
}
|
||||
return Math.min(count, MAX_COUNT);
|
||||
}
|
||||
|
||||
function normalizeReplyContent(value) {
|
||||
const content = typeof value === 'string' ? value.trim() : '';
|
||||
if (!content) {
|
||||
throw new InteractionValidationError('EMPTY_DOUYIN_REPLY', '回复内容不能为空');
|
||||
}
|
||||
if (content.length > MAX_REPLY_LENGTH) {
|
||||
throw new InteractionValidationError(
|
||||
'DOUYIN_REPLY_TOO_LONG',
|
||||
`回复内容不能超过 ${MAX_REPLY_LENGTH} 个字符`,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function normalizeExpectedAccountUid(value) {
|
||||
const uid = typeof value === 'string' ? value.trim() : '';
|
||||
if (!uid || uid.length > 256 || /[\u0000-\u001f\u007f]/.test(uid)) {
|
||||
throw new InteractionValidationError('INVALID_DOUYIN_ACCOUNT_UID', '缺少有效的抖音账号 UID');
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
export function doesDouyinAccountMatch(expectedAccountUid, account) {
|
||||
const expected = typeof expectedAccountUid === 'string' ? expectedAccountUid.trim() : '';
|
||||
if (!expected || !account || typeof account !== 'object') return false;
|
||||
return [account.uid, account.numericUid, account.uniqueId]
|
||||
.some(value => typeof value === 'string' && value === expected);
|
||||
}
|
||||
|
||||
export async function executeDouyinSessionRequest(options = {}) {
|
||||
function getAvatarUrl(user) {
|
||||
return [
|
||||
...(user?.avatar_thumb?.url_list || []),
|
||||
...(user?.avatar_medium?.url_list || []),
|
||||
].find(url => typeof url === 'string' && url.startsWith('https://')) || '';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const userResponse = await fetch('/web/api/media/user/info/', {
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json, text/plain, */*' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!userResponse.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: userResponse.status === 401 || userResponse.status === 403
|
||||
? 'DOUYIN_LOGIN_REQUIRED'
|
||||
: 'DOUYIN_ACCOUNT_HTTP_ERROR',
|
||||
message: `抖音账号接口返回 HTTP ${userResponse.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await userResponse.json();
|
||||
const user = data?.user || data?.user_info;
|
||||
if (Number(data?.status_code) !== 0 || !user) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'DOUYIN_LOGIN_REQUIRED',
|
||||
message: data?.status_msg || '当前浏览器尚未登录抖音创作者中心',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const account = {
|
||||
uid: String(user.sec_uid || user.uid || ''),
|
||||
numericUid: String(user.uid || ''),
|
||||
uniqueId: String(user.unique_id || ''),
|
||||
nickname: typeof user.nickname === 'string' ? user.nickname : '',
|
||||
avatarUrl: getAvatarUrl(user),
|
||||
};
|
||||
if (!account.uid) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'DOUYIN_ACCOUNT_ID_MISSING',
|
||||
message: '抖音账号接口未返回可核对的账号 UID',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.includeCsrf) {
|
||||
return { ok: true, result: { account } };
|
||||
}
|
||||
|
||||
const csrfResponse = await fetch('/web/api/media/anchor/search', {
|
||||
method: 'HEAD',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'X-Secsdk-Csrf-Request': '1',
|
||||
'X-Secsdk-Csrf-Version': '1.2.22',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const csrfHeader = csrfResponse.headers.get('x-ware-csrf-token') || '';
|
||||
const csrfParts = csrfHeader.split(',').map(value => value.trim()).filter(Boolean);
|
||||
const csrfToken = csrfParts[1] || csrfParts[0] || '';
|
||||
if (!csrfResponse.ok || !csrfToken) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'DOUYIN_CSRF_TOKEN_FAILED',
|
||||
message: '无法获取抖音回复所需的安全令牌,请刷新创作者中心后重试',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, result: { account, csrfToken } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: error?.name === 'AbortError'
|
||||
? 'DOUYIN_ACCOUNT_REQUEST_TIMEOUT'
|
||||
: 'DOUYIN_ACCOUNT_REQUEST_FAILED',
|
||||
message: error?.name === 'AbortError'
|
||||
? '抖音账号读取超时,请刷新创作者中心后重试'
|
||||
: (error instanceof Error ? error.message : String(error)),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDouyinReplyRequest(request) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.set('aweme_id', request.workId);
|
||||
body.set('reply_id', request.commentId);
|
||||
body.set('text', request.content);
|
||||
body.set('one_level_comment_rank', '1');
|
||||
|
||||
const response = await fetch('/aweme/v1/web/comment/publish/?aid=6383', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
'x-secsdk-csrf-token': request.csrfToken,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: response.status === 401 || response.status === 403
|
||||
? 'DOUYIN_LOGIN_REQUIRED'
|
||||
: 'DOUYIN_REPLY_HTTP_ERROR',
|
||||
message: `抖音回复接口返回 HTTP ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (Number(data?.status_code) !== 0) {
|
||||
const message = data?.status_msg || `抖音回复接口返回状态 ${data?.status_code}`;
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: /验证|频繁|风险|安全/.test(message)
|
||||
? 'DOUYIN_RISK_CONTROL'
|
||||
: /登录/.test(message)
|
||||
? 'DOUYIN_LOGIN_REQUIRED'
|
||||
: 'DOUYIN_REPLY_API_ERROR',
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
platform: 'douyin',
|
||||
workId: request.workId,
|
||||
parentCommentId: request.commentId,
|
||||
platformCommentId: String(data?.comment?.cid || data?.cid || ''),
|
||||
success: true,
|
||||
source: 'douyin-web',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: error?.name === 'AbortError' ? 'DOUYIN_REPLY_TIMEOUT' : 'DOUYIN_REPLY_REQUEST_FAILED',
|
||||
message: error?.name === 'AbortError'
|
||||
? '抖音回复请求超时,请确认页面可正常访问后重试'
|
||||
: (error instanceof Error ? error.message : String(error)),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDouyinRequest(method, payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new InteractionValidationError('INVALID_INTERACTION_PAYLOAD', '互动请求参数格式不正确');
|
||||
}
|
||||
|
||||
const workId = normalizeId(payload.workId, '作品 ID');
|
||||
|
||||
if (method === 'replyDouyinComment') {
|
||||
return {
|
||||
workId,
|
||||
commentId: normalizeId(payload.commentId, '评论 ID'),
|
||||
content: normalizeReplyContent(payload.content),
|
||||
expectedAccountUid: normalizeExpectedAccountUid(payload.expectedAccountUid),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
workId,
|
||||
cursor: normalizeCursor(payload.cursor),
|
||||
count: normalizeCount(payload.count),
|
||||
};
|
||||
|
||||
if (method === 'listDouyinReplies') {
|
||||
normalized.commentId = normalizeId(payload.commentId, '评论 ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function executeDouyinCommentRequest(request) {
|
||||
function getAvatarUrl(user) {
|
||||
const candidates = [
|
||||
...(user?.avatar_thumb?.url_list || []),
|
||||
...(user?.avatar_medium?.url_list || []),
|
||||
];
|
||||
return candidates.find(url => typeof url === 'string' && url.startsWith('https://')) || '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function normalizeComment(comment, parentCommentId) {
|
||||
const id = String(comment?.cid || '');
|
||||
const nestedReplies = Array.isArray(comment?.reply_comment)
|
||||
? comment.reply_comment.map(reply => normalizeComment(reply, id)).filter(reply => reply.id)
|
||||
: [];
|
||||
const createdAtSeconds = toFiniteNumber(comment?.create_time);
|
||||
|
||||
return {
|
||||
id,
|
||||
workId: String(comment?.aweme_id || request.workId),
|
||||
parentCommentId: parentCommentId || undefined,
|
||||
rootCommentId: String(parentCommentId || comment?.root_comment_id || id || ''),
|
||||
text: typeof comment?.text === 'string' ? comment.text : '',
|
||||
createdAt: createdAtSeconds > 0 ? new Date(createdAtSeconds * 1000).toISOString() : undefined,
|
||||
likeCount: toFiniteNumber(comment?.digg_count),
|
||||
replyCount: toFiniteNumber(
|
||||
parentCommentId
|
||||
? comment?.reply_comment_total
|
||||
: comment?.reply_comment_total ?? comment?.comment_reply_total,
|
||||
nestedReplies.length,
|
||||
),
|
||||
ipLabel: typeof comment?.ip_label === 'string' ? comment.ip_label : '',
|
||||
isAuthor: comment?.label_text === '作者' || Number(comment?.label_type) === 1,
|
||||
author: {
|
||||
name: typeof comment?.user?.nickname === 'string' ? comment.user.nickname : '',
|
||||
uid: String(comment?.user?.uid || ''),
|
||||
secUid: String(comment?.user?.sec_uid || ''),
|
||||
avatarUrl: getAvatarUrl(comment?.user),
|
||||
},
|
||||
replies: nestedReplies,
|
||||
};
|
||||
}
|
||||
|
||||
function getBrowserVersion() {
|
||||
const match = navigator.userAgent.match(/(?:Chrome|Edg)\/(\d+(?:\.\d+){0,3})/);
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
const commonParams = {
|
||||
device_platform: 'webapp',
|
||||
aid: '6383',
|
||||
channel: 'channel_pc_web',
|
||||
item_type: '0',
|
||||
cut_version: '1',
|
||||
update_version_code: '170400',
|
||||
pc_client_type: '1',
|
||||
pc_libra_divert: 'Windows',
|
||||
support_h265: '1',
|
||||
support_dash: '1',
|
||||
cpu_core_num: String(navigator.hardwareConcurrency || 8),
|
||||
version_code: '170400',
|
||||
version_name: '17.4.0',
|
||||
cookie_enabled: String(navigator.cookieEnabled),
|
||||
screen_width: String(screen.width || 1920),
|
||||
screen_height: String(screen.height || 1080),
|
||||
browser_language: navigator.language || 'zh-CN',
|
||||
browser_platform: navigator.platform || 'Win32',
|
||||
browser_name: navigator.userAgent.includes('Edg/') ? 'Edge' : 'Chrome',
|
||||
browser_version: getBrowserVersion(),
|
||||
browser_online: String(navigator.onLine),
|
||||
engine_name: 'Blink',
|
||||
os_name: 'Windows',
|
||||
device_memory: String(navigator.deviceMemory || 8),
|
||||
platform: 'PC',
|
||||
downlink: String(connection?.downlink || 10),
|
||||
effective_type: connection?.effectiveType || '4g',
|
||||
round_trip_time: String(connection?.rtt || 50),
|
||||
};
|
||||
|
||||
const isReplies = request.kind === 'replies';
|
||||
const params = new URLSearchParams({
|
||||
...commonParams,
|
||||
cursor: request.cursor,
|
||||
count: String(request.count),
|
||||
...(isReplies
|
||||
? { item_id: request.workId, comment_id: request.commentId }
|
||||
: { aweme_id: request.workId }),
|
||||
});
|
||||
const endpoint = isReplies
|
||||
? '/aweme/v1/web/comment/list/reply/'
|
||||
: '/aweme/v1/web/comment/list/';
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
try {
|
||||
const response = await fetch(`${endpoint}?${params}`, {
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json, text/plain, */*' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: response.status === 401 || response.status === 403
|
||||
? 'DOUYIN_LOGIN_REQUIRED'
|
||||
: 'DOUYIN_HTTP_ERROR',
|
||||
message: `抖音评论接口返回 HTTP ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (Number(data?.status_code) !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'DOUYIN_API_ERROR',
|
||||
message: data?.status_msg || `抖音评论接口返回状态 ${data?.status_code}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
platform: 'douyin',
|
||||
workId: request.workId,
|
||||
parentCommentId: isReplies ? request.commentId : undefined,
|
||||
items: (Array.isArray(data.comments) ? data.comments : [])
|
||||
.map(comment => normalizeComment(comment, isReplies ? request.commentId : undefined))
|
||||
.filter(comment => comment.id),
|
||||
cursor: String(data.cursor ?? ''),
|
||||
hasMore: Boolean(data.has_more),
|
||||
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
|
||||
source: 'douyin-web',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: error?.name === 'AbortError' ? 'DOUYIN_REQUEST_TIMEOUT' : 'DOUYIN_REQUEST_FAILED',
|
||||
message: error?.name === 'AbortError'
|
||||
? '抖音评论请求超时,请确认页面可以正常访问后重试'
|
||||
: (error instanceof Error ? error.message : String(error)),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AiToEarn - 内容营销助手",
|
||||
"version": "1.2.0",
|
||||
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测与多平台文章分发",
|
||||
"version": "1.15.0",
|
||||
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"clipboardRead",
|
||||
"cookies",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
"debugger",
|
||||
@@ -83,7 +82,7 @@
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*"
|
||||
],
|
||||
"js": ["distribution-page-bridge.js"],
|
||||
"js": ["distribution-page-bridge.js", "interaction-page-bridge.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
},
|
||||
@@ -93,7 +92,7 @@
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*"
|
||||
],
|
||||
"js": ["distribution-bridge.js"],
|
||||
"js": ["distribution-bridge.js", "interaction-bridge.js"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "aitoearn-chrome-extension",
|
||||
"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",
|
||||
"release:verify": "node scripts/verify-release.mjs"
|
||||
}
|
||||
}
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
@@ -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));
|
||||
|
||||
+27
-2
@@ -17,6 +17,8 @@ $requiredPaths = @(
|
||||
'content.js',
|
||||
'distribution-bridge.js',
|
||||
'distribution-page-bridge.js',
|
||||
'interaction-bridge.js',
|
||||
'interaction-page-bridge.js',
|
||||
'popup.html',
|
||||
'popup.css',
|
||||
'popup.js',
|
||||
@@ -24,7 +26,8 @@ $requiredPaths = @(
|
||||
'options.css',
|
||||
'options.js',
|
||||
'icons',
|
||||
'distribution'
|
||||
'distribution',
|
||||
'interaction'
|
||||
)
|
||||
|
||||
foreach ($relativePath in $requiredPaths) {
|
||||
@@ -47,6 +50,16 @@ foreach ($javascriptFile in $javascriptFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
& node (Join-Path $PSScriptRoot 'test-interaction.mjs')
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Interaction bridge validation failed'
|
||||
}
|
||||
|
||||
& node (Join-Path $PSScriptRoot 'test-distribution.mjs')
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Distribution bridge validation failed'
|
||||
}
|
||||
|
||||
$distRoot = Join-Path $repoRoot 'dist'
|
||||
$stageRoot = Join-Path $distRoot "aitoearn-extension-v$version"
|
||||
$archivePath = Join-Path $distRoot "aitoearn-extension-v$version.zip"
|
||||
@@ -89,7 +102,19 @@ if (-not (Test-Path -LiteralPath (Join-Path $verificationRoot 'manifest.json')))
|
||||
throw 'Packaged archive does not contain manifest.json at its root'
|
||||
}
|
||||
|
||||
$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
||||
$archiveStream = $null
|
||||
try {
|
||||
$archiveStream = [System.IO.File]::OpenRead($archivePath)
|
||||
$archiveHashBytes = $sha256.ComputeHash($archiveStream)
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $archiveStream) {
|
||||
$archiveStream.Dispose()
|
||||
}
|
||||
$sha256.Dispose()
|
||||
}
|
||||
$archiveHash = -join ($archiveHashBytes | ForEach-Object { $_.ToString('x2') })
|
||||
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
|
||||
Remove-Item -LiteralPath $verificationRoot -Recurse -Force
|
||||
Remove-Item -LiteralPath $stageRoot -Recurse -Force
|
||||
|
||||
@@ -0,0 +1,884 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
BaijiahaoLoginConfig,
|
||||
ToutiaoLoginConfig,
|
||||
ZhihuLoginConfig,
|
||||
} from '../distribution/cose/detection/src/configs.js'
|
||||
import { parseCsdnAccount } from '../distribution/cose/detection/src/platforms/csdn.js'
|
||||
import { parseSegmentFaultAccount } from '../distribution/cose/detection/src/platforms/segmentfault.js'
|
||||
import {
|
||||
CSDNPlatform,
|
||||
inspectCsdnTaskState,
|
||||
parseCsdnPublishedUrl,
|
||||
} from '../distribution/cose/core/platforms/csdn.js'
|
||||
import {
|
||||
inspectSegmentFaultTaskState,
|
||||
parseSegmentFaultPublishedUrl,
|
||||
SegmentFaultPlatform,
|
||||
} from '../distribution/cose/core/platforms/segmentfault.js'
|
||||
import { parseJianshuAccount } from '../distribution/cose/detection/src/platforms/jianshu.js'
|
||||
import { inspectJianshuTaskState } from '../distribution/cose/core/platforms/jianshu.js'
|
||||
import { inspectJuejinTaskState } from '../distribution/cose/core/platforms/juejin.js'
|
||||
import { inspectZhihuTaskState } from '../distribution/cose/core/platforms/zhihu.js'
|
||||
import {
|
||||
inspectToutiaoTaskState,
|
||||
renderToutiaoMarkdown,
|
||||
} from '../distribution/cose/core/platforms/toutiao.js'
|
||||
import { inspectBaijiahaoTaskState } from '../distribution/cose/core/platforms/baijiahao.js'
|
||||
import { parseWangyihaoAccount } from '../distribution/cose/detection/src/platforms/wangyihao.js'
|
||||
import { inspectWangyihaoTaskState } from '../distribution/cose/core/platforms/wangyihao.js'
|
||||
import { parseSohuAccount } from '../distribution/cose/detection/src/platforms/sohu.js'
|
||||
import { inspectSohuTaskState } from '../distribution/cose/core/platforms/sohu.js'
|
||||
import { parseAliyunAccount } from '../distribution/cose/detection/src/platforms/aliyun.js'
|
||||
import {
|
||||
AliyunPlatform,
|
||||
inspectAliyunTaskState,
|
||||
parseAliyunPublishedUrl,
|
||||
} from '../distribution/cose/core/platforms/aliyun.js'
|
||||
import { parseTencentCloudAccount } from '../distribution/cose/detection/src/platforms/tencentcloud.js'
|
||||
import {
|
||||
inspectTencentCloudTaskState,
|
||||
parseTencentCloudPublishedUrl,
|
||||
TencentCloudPlatform,
|
||||
} from '../distribution/cose/core/platforms/tencentcloud.js'
|
||||
import { parseInfoQAccount } from '../distribution/cose/detection/src/platforms/infoq.js'
|
||||
import {
|
||||
inspectInfoQTaskState,
|
||||
parseInfoQDraftCreation,
|
||||
} from '../distribution/cose/core/platforms/infoq.js'
|
||||
import {
|
||||
AUTO_FILL_PLATFORM_IDS,
|
||||
INSPECT_HANDLERS,
|
||||
PLATFORMS,
|
||||
SYNC_HANDLERS,
|
||||
} from '../distribution/cose/core/platforms/index.js'
|
||||
import { canReturnExistingPlatformTaskState } from '../distribution/cose/core/task-state.js'
|
||||
|
||||
assert.deepEqual(
|
||||
inspectJuejinTaskState('https://juejin.cn/editor/drafts/new'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://juejin.cn/editor/drafts/new',
|
||||
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectJuejinTaskState('https://juejin.cn/editor/drafts/123456'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://juejin.cn/editor/drafts/123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认掘金草稿地址',
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectJuejinTaskState('https://juejin.cn/post/987654'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://juejin.cn/post/987654',
|
||||
platformWorkId: '987654',
|
||||
message: '已确认掘金文章公开地址',
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
inspectJuejinTaskState('https://juejin.cn/creator/content/article').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/editor/drafts/new')),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/post/987654')),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/creator/content/article')),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
parseJianshuAccount({
|
||||
data: {
|
||||
id: 12345,
|
||||
nickname: '简书作者',
|
||||
avatar: 'https://upload.jianshu.io/avatar.png',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: '12345',
|
||||
username: '简书作者',
|
||||
avatar: 'https://upload.jianshu.io/avatar.png',
|
||||
},
|
||||
)
|
||||
assert.equal(parseJianshuAccount({ data: { nickname: '无稳定标识' } }).platformUid, undefined)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectJianshuTaskState('https://www.jianshu.com/writer#/notebooks/12/notes/34'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://www.jianshu.com/writer#/notebooks/12/notes/34',
|
||||
platformWorkId: '34',
|
||||
message: '已确认简书草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectJianshuTaskState(
|
||||
'https://www.jianshu.com/writer#/notebooks/12/notes/34',
|
||||
'https://www.jianshu.com/p/published-note',
|
||||
),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://www.jianshu.com/p/published-note',
|
||||
platformWorkId: 'published-note',
|
||||
message: '已确认简书文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectJianshuTaskState('https://www.jianshu.com/writer').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectJianshuTaskState('https://www.jianshu.com/writer#/notebooks/12/notes/34'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
ZhihuLoginConfig.getUserInfo({
|
||||
id: 'zhihu-user-1',
|
||||
name: '知乎作者',
|
||||
avatar_url: 'https://picx.zhimg.com/avatar.jpg',
|
||||
}),
|
||||
{
|
||||
platformUid: 'zhihu-user-1',
|
||||
username: '知乎作者',
|
||||
avatar: 'https://picx.zhimg.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
ZhihuLoginConfig.getUserInfo({ name: '无稳定标识' }).platformUid,
|
||||
undefined,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://zhuanlan.zhihu.com/write',
|
||||
message: '内容已填充到知乎新建编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write/draft-123'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://zhuanlan.zhihu.com/write/draft-123',
|
||||
platformWorkId: 'draft-123',
|
||||
message: '已确认知乎草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/p/987654'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://zhuanlan.zhihu.com/p/987654',
|
||||
platformWorkId: '987654',
|
||||
message: '已确认知乎文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectZhihuTaskState('https://www.zhihu.com/creator').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write/draft-123'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
ToutiaoLoginConfig.getUserInfo({
|
||||
code: 0,
|
||||
data: {
|
||||
user_id: 'toutiao-user-1',
|
||||
name: '头条作者',
|
||||
avatar_url: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: 'toutiao-user-1',
|
||||
username: '头条作者',
|
||||
avatar: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
ToutiaoLoginConfig.checkLogin({ code: 0, data: { name: '无稳定标识' } }),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://mp.toutiao.com/profile_v4/graphic/publish',
|
||||
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认今日头条草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectToutiaoTaskState('https://www.toutiao.com/article/987654321/'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://www.toutiao.com/article/987654321/',
|
||||
platformWorkId: '987654321',
|
||||
message: '已确认今日头条文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/articles').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draftId=789'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.match(
|
||||
renderToutiaoMarkdown('# 标题\n\n- 第一项\n- 第二项\n\n\n\n**重点**'),
|
||||
/<h1>标题<\/h1><ul><li>第一项<\/li><li>第二项<\/li><\/ul><p><img src="https:\/\/example\.com\/image\.png" alt="配图" \/><\/p><p><strong>重点<\/strong><\/p>/,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
BaijiahaoLoginConfig.getUserInfo({
|
||||
errno: 0,
|
||||
data: {
|
||||
user: {
|
||||
uk: 123456,
|
||||
name: '百家号作者',
|
||||
avatar: 'https://himg.bdimg.com/avatar.jpg',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: '123456',
|
||||
username: '百家号作者',
|
||||
avatar: 'https://himg.bdimg.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
BaijiahaoLoginConfig.checkLogin({ errno: 0, data: { user: { name: '无稳定标识' } } }),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/edit?type=news'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://baijiahao.baidu.com/builder/rc/edit?type=news',
|
||||
message: '内容已填充到百家号编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/edit?article_id=456'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://baijiahao.baidu.com/builder/rc/edit?article_id=456',
|
||||
platformWorkId: '456',
|
||||
message: '已确认百家号草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/s?id=123456789'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://baijiahao.baidu.com/s?id=123456789',
|
||||
platformWorkId: '123456789',
|
||||
message: '已确认百家号文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/manager').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectBaijiahaoTaskState('https://baijiahao.baidu.com/builder/rc/edit?draftId=789'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
parseWangyihaoAccount({
|
||||
code: 1,
|
||||
data: {
|
||||
wemediaId: 123456,
|
||||
tname: '网易号作者',
|
||||
icon: 'https://img.163.com/avatar.jpg',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: '123456',
|
||||
username: '网易号作者',
|
||||
avatar: 'https://img.163.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
parseWangyihaoAccount({ code: 1, data: { tname: '无稳定标识' } }).platformUid,
|
||||
undefined,
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
inspectWangyihaoTaskState('https://mp.163.com/#/article-publish'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://mp.163.com/#/article-publish',
|
||||
message: '内容已填充到网易号编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectWangyihaoTaskState('https://mp.163.com/#/article-publish', undefined, true),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://mp.163.com/#/article-publish',
|
||||
message: '已确认网易号编辑器显示草稿保存成功',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectWangyihaoTaskState('https://www.163.com/dy/article/IMABCDE123.html'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://www.163.com/dy/article/IMABCDE123.html',
|
||||
platformWorkId: 'IMABCDE123',
|
||||
message: '已确认网易号文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectWangyihaoTaskState('https://mp.163.com/#/article-manage').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectWangyihaoTaskState('https://mp.163.com/#/article-publish', undefined, true),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(typeof SYNC_HANDLERS.wangyi, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.wangyi, 'function')
|
||||
|
||||
const wangyihaoDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/wangyihao.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(wangyihaoDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
assert.deepEqual(
|
||||
parseSohuAccount({
|
||||
success: true,
|
||||
data: {
|
||||
data: [{
|
||||
accounts: [{
|
||||
accountId: 'sohu-account-1',
|
||||
nickName: '搜狐号作者',
|
||||
avatar: '//img.sohu.com/avatar.jpg',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: 'sohu-account-1',
|
||||
username: '搜狐号作者',
|
||||
avatar: 'https://img.sohu.com/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
parseSohuAccount({ success: true, data: { data: [{ accounts: [{ nickName: '无稳定标识' }] }] } }).platformUid,
|
||||
undefined,
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/create'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
|
||||
message: '内容已填充到搜狐号编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/create', undefined, true),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
|
||||
message: '已确认搜狐号编辑器显示草稿保存成功',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectSohuTaskState('https://www.sohu.com/a/123456789_123456789'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://www.sohu.com/a/123456789_123456789',
|
||||
platformWorkId: '123456789_123456789',
|
||||
message: '已确认搜狐号文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/manage').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectSohuTaskState('https://mp.sohu.com/mpfe/v4/main/content/article/create', undefined, true),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(typeof SYNC_HANDLERS.sohu, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.sohu, 'function')
|
||||
|
||||
const sohuDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/sohu.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(sohuDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
assert.deepEqual(
|
||||
parseAliyunAccount({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
userId: 123456,
|
||||
nickname: '阿里云作者',
|
||||
avatarUrl: 'https://example.com/aliyun-avatar.jpg',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: '123456',
|
||||
username: '阿里云作者',
|
||||
avatar: 'https://example.com/aliyun-avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
parseAliyunAccount({ success: true, data: { nickname: '无稳定标识' } }).platformUid,
|
||||
undefined,
|
||||
)
|
||||
assert.equal(AliyunPlatform.autoFillSupported, undefined)
|
||||
assert.deepEqual(
|
||||
inspectAliyunTaskState('https://developer.aliyun.com/article/new'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://developer.aliyun.com/article/new',
|
||||
message: '内容已填充到阿里云开发者社区编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectAliyunTaskState('https://developer.aliyun.com/article/edit?articleId=123456'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://developer.aliyun.com/article/edit?articleId=123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认阿里云开发者社区草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseAliyunPublishedUrl('https://developer.aliyun.com/article/987654'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://developer.aliyun.com/article/987654',
|
||||
platformWorkId: '987654',
|
||||
message: '已确认阿里云开发者社区文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectAliyunTaskState('https://developer.aliyun.com/article/manage').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(typeof SYNC_HANDLERS.aliyun, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.aliyun, 'function')
|
||||
|
||||
const aliyunDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/aliyun.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(aliyunDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
assert.deepEqual(
|
||||
parseTencentCloudAccount(
|
||||
'<script>{"creatorInfo":{"uin":123456,"nickname":"腾讯云作者","avatarUrl":"https://example.com/tencent-avatar.jpg"}}</script>',
|
||||
),
|
||||
{
|
||||
platformUid: '123456',
|
||||
username: '腾讯云作者',
|
||||
avatar: 'https://example.com/tencent-avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
parseTencentCloudAccount('<script>{"creatorInfo":{"nickname":"无稳定标识"}}</script>').platformUid,
|
||||
undefined,
|
||||
)
|
||||
assert.equal(TencentCloudPlatform.autoFillSupported, true)
|
||||
assert.deepEqual(
|
||||
inspectTencentCloudTaskState('https://cloud.tencent.com/developer/article/write-new'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://cloud.tencent.com/developer/article/write-new',
|
||||
message: '内容已填充到腾讯云开发者社区编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectTencentCloudTaskState('https://cloud.tencent.com/developer/article/edit?articleId=123456'),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://cloud.tencent.com/developer/article/edit?articleId=123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认腾讯云开发者社区草稿地址',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseTencentCloudPublishedUrl('https://cloud.tencent.com/developer/article/987654'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://cloud.tencent.com/developer/article/987654',
|
||||
platformWorkId: '987654',
|
||||
message: '已确认腾讯云开发者社区文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectTencentCloudTaskState('https://cloud.tencent.com/developer/article/manage').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(typeof SYNC_HANDLERS.tencentcloud, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.tencentcloud, 'function')
|
||||
|
||||
const tencentCloudDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/tencentcloud.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(tencentCloudDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
const backgroundSource = await readFile(
|
||||
new URL('../distribution/cose/background.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const rootBackgroundSource = await readFile(new URL('../background.js', import.meta.url), 'utf8')
|
||||
const manifestText = await readFile(new URL('../manifest.json', import.meta.url), 'utf8')
|
||||
const manifest = JSON.parse(manifestText.replace(/^\uFEFF/, ''))
|
||||
const supportedDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/supported-platforms.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const platformIndexSource = await readFile(
|
||||
new URL('../distribution/cose/core/platforms/index.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const expectedAutoFillPlatformIds = new Set([
|
||||
'csdn',
|
||||
'wechat',
|
||||
'juejin',
|
||||
'jianshu',
|
||||
'zhihu',
|
||||
'toutiao',
|
||||
'baijiahao',
|
||||
'wangyi',
|
||||
'sohu',
|
||||
'infoq',
|
||||
'tencentcloud',
|
||||
'aliyun',
|
||||
'segmentfault',
|
||||
])
|
||||
assert.deepEqual(AUTO_FILL_PLATFORM_IDS, expectedAutoFillPlatformIds)
|
||||
assert.deepEqual(
|
||||
new Set(PLATFORMS.filter(platform => platform.autoFillSupported === true).map(platform => platform.id)),
|
||||
expectedAutoFillPlatformIds,
|
||||
)
|
||||
assert.equal(PLATFORMS.every(platform => typeof platform.autoFillSupported === 'boolean'), true)
|
||||
assert.match(backgroundSource, /platform\.autoFillSupported !== true/)
|
||||
assert.match(backgroundSource, /return \{ platforms: PLATFORMS\.filter\(platform => platform\.autoFillSupported === true\) \}/)
|
||||
assert.match(backgroundSource, /getRequestedAutoFillPlatforms\(request\.platforms\)/)
|
||||
assert.match(backgroundSource, /detectSupportedPlatformUser/)
|
||||
assert.doesNotMatch(backgroundSource, /from '\.\/detection\/index\.js'/)
|
||||
assert.doesNotMatch(platformIndexSource, /from '\.\.\/\.\.\/detection\/index\.js'/)
|
||||
assert.doesNotMatch(supportedDetectorSource, /detectUser|utils\.js|chrome\.cookies|Cookie\s*:/)
|
||||
assert.equal(manifest.permissions.includes('cookies'), false)
|
||||
assert.match(rootBackgroundSource, /AUTO_FILL_PLATFORM_IDS\.has\(platformId\)/)
|
||||
|
||||
for (const detectorPath of [
|
||||
'../distribution/cose/detection/src/platforms/wechat.js',
|
||||
'../distribution/cose/detection/src/platforms/csdn.js',
|
||||
'../distribution/cose/detection/src/platforms/jianshu.js',
|
||||
'../distribution/cose/detection/src/platforms/segmentfault.js',
|
||||
'../distribution/cose/detection/src/platforms/wangyihao.js',
|
||||
'../distribution/cose/detection/src/platforms/sohu.js',
|
||||
'../distribution/cose/detection/src/platforms/infoq.js',
|
||||
'../distribution/cose/detection/src/platforms/tencentcloud.js',
|
||||
'../distribution/cose/detection/src/platforms/aliyun.js',
|
||||
]) {
|
||||
const detectorSource = await readFile(new URL(detectorPath, import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(detectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
}
|
||||
|
||||
const wechatDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/wechat.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.match(wechatDetectorSource, /platformUid: wxData\.user_name \|\| wxData\.fakeid \|\| ''/)
|
||||
assert.match(wechatDetectorSource, /cachedUser\.platformUid/)
|
||||
|
||||
assert.equal(CSDNPlatform.autoFillSupported, undefined)
|
||||
assert.equal(SegmentFaultPlatform.autoFillSupported, undefined)
|
||||
assert.equal(PLATFORMS.find(platform => platform.id === 'csdn')?.autoFillSupported, true)
|
||||
assert.equal(PLATFORMS.find(platform => platform.id === 'segmentfault')?.autoFillSupported, true)
|
||||
assert.equal(PLATFORMS.find(platform => platform.id === 'aliyun')?.autoFillSupported, true)
|
||||
assert.equal(typeof SYNC_HANDLERS.csdn, 'function')
|
||||
assert.equal(typeof SYNC_HANDLERS.segmentfault, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.csdn, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.segmentfault, 'function')
|
||||
assert.match(platformIndexSource, /syncAliyunContent/)
|
||||
assert.match(platformIndexSource, /inspectAliyunTask/)
|
||||
assert.match(backgroundSource, /__coseCsdnProfile/)
|
||||
assert.match(backgroundSource, /chrome\.tabs\.query\(\{ url: 'https:\/\/editor\.csdn\.net\/md\/\*' \}\)/)
|
||||
assert.doesNotMatch(backgroundSource, /chrome\.tabs\.query\(\{ url: '\*:\/\/\*\.csdn\.net\/\*' \}\)/)
|
||||
assert.match(
|
||||
backgroundSource,
|
||||
/async function openPlatformTask[\s\S]*?platform\.autoFillSupported !== true[\s\S]*?const account = await getPlatformAccount/,
|
||||
)
|
||||
assert.match(
|
||||
backgroundSource,
|
||||
/async function inspectPlatformTask[\s\S]*?platform\.autoFillSupported !== true[\s\S]*?const account = await getPlatformAccount/,
|
||||
)
|
||||
|
||||
const csdnPlatformSource = await readFile(
|
||||
new URL('../distribution/cose/core/platforms/csdn.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.match(csdnPlatformSource, /syncCsdnContent/)
|
||||
assert.match(csdnPlatformSource, /fillCsdnContent/)
|
||||
|
||||
const segmentFaultPlatformSource = await readFile(
|
||||
new URL('../distribution/cose/core/platforms/segmentfault.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.match(segmentFaultPlatformSource, /syncSegmentFaultContent/)
|
||||
assert.match(segmentFaultPlatformSource, /fillSegmentFaultContent/)
|
||||
|
||||
const popupSource = await readFile(new URL('../popup.js', import.meta.url), 'utf8')
|
||||
assert.match(popupSource, /platform\.autoFillSupported === true/)
|
||||
|
||||
const optionsSource = await readFile(new URL('../options.js', import.meta.url), 'utf8')
|
||||
assert.match(optionsSource, /platform\.autoFillSupported === true/)
|
||||
|
||||
const csdnDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/csdn.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(csdnDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
const segmentFaultDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/segmentfault.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(segmentFaultDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
assert.deepEqual(
|
||||
parseCsdnAccount({
|
||||
data: {
|
||||
userName: 'csdn-author',
|
||||
nickname: 'CSDN 作者',
|
||||
avatarUrl: 'https://profile.csdnimg.cn/avatar.png',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: 'csdn-author',
|
||||
username: 'CSDN 作者',
|
||||
avatar: 'https://profile.csdnimg.cn/avatar.png',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseCsdnAccount({
|
||||
data: {
|
||||
nickname: '仅昵称',
|
||||
blog_url: 'https://blog.csdn.net/csdn-blog-owner',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: 'csdn-blog-owner',
|
||||
username: '仅昵称',
|
||||
avatar: '',
|
||||
},
|
||||
)
|
||||
assert.equal(parseCsdnAccount({ data: { nickname: '无稳定标识' } }).platformUid, undefined)
|
||||
assert.deepEqual(
|
||||
inspectCsdnTaskState('https://editor.csdn.net/md/'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://editor.csdn.net/md/',
|
||||
message: '内容仍在 CSDN 新建编辑器中,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectCsdnTaskState('https://editor.csdn.net/md/?articleId=123456', undefined, true),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://editor.csdn.net/md/?articleId=123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认 CSDN 编辑器显示草稿保存成功',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseCsdnPublishedUrl('https://blog.csdn.net/csdn-author/article/details/123456'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://blog.csdn.net/csdn-author/article/details/123456',
|
||||
platformWorkId: '123456',
|
||||
message: '已确认 CSDN 文章公开地址',
|
||||
},
|
||||
)
|
||||
|
||||
const segmentFaultSession = JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
initialState: {
|
||||
global: {
|
||||
sessionUser: {
|
||||
user: {
|
||||
id: 123456,
|
||||
name: '思否作者',
|
||||
avatar_url: 'https://cdn.segmentfault.com/avatar.png',
|
||||
},
|
||||
},
|
||||
sessionInfo: { login: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.deepEqual(
|
||||
parseSegmentFaultAccount(`<script id="__NEXT_DATA__" type="application/json">${segmentFaultSession}</script>`),
|
||||
{
|
||||
platformUid: '123456',
|
||||
username: '思否作者',
|
||||
avatar: 'https://cdn.segmentfault.com/avatar.png',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseSegmentFaultAccount(
|
||||
'<script id="__NEXT_DATA__">{"props":{"pageProps":{"initialState":{"global":{"sessionUser":null,"sessionInfo":{"id":"sf-user-1","login":true}}}}}}</script>',
|
||||
),
|
||||
{
|
||||
platformUid: 'sf-user-1',
|
||||
username: '',
|
||||
avatar: '',
|
||||
},
|
||||
)
|
||||
assert.equal(parseSegmentFaultAccount('<html></html>').platformUid, undefined)
|
||||
assert.deepEqual(
|
||||
inspectSegmentFaultTaskState('https://segmentfault.com/write'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://segmentfault.com/write',
|
||||
message: '内容已填充到思否新建编辑器,请检查后保存草稿或发布',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectSegmentFaultTaskState('https://segmentfault.com/write?draftId=abc123', undefined, true),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://segmentfault.com/write?draftId=abc123',
|
||||
platformWorkId: 'abc123',
|
||||
message: '已确认思否编辑器显示草稿保存成功',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseSegmentFaultPublishedUrl('https://segmentfault.com/a/1190000041234567'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://segmentfault.com/a/1190000041234567',
|
||||
platformWorkId: '1190000041234567',
|
||||
message: '已确认思否文章公开地址',
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
parseInfoQAccount({
|
||||
code: 0,
|
||||
data: {
|
||||
uid: 123456,
|
||||
nickname: 'InfoQ 作者',
|
||||
avatar: 'https://static001.infoq.cn/avatar.jpg',
|
||||
},
|
||||
}),
|
||||
{
|
||||
platformUid: '123456',
|
||||
username: 'InfoQ 作者',
|
||||
avatar: 'https://static001.infoq.cn/avatar.jpg',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
parseInfoQAccount({ code: 0, data: { nickname: '无稳定标识' } }).platformUid,
|
||||
undefined,
|
||||
)
|
||||
assert.equal(
|
||||
parseInfoQDraftCreation({ code: 0, data: { id: 'infoq-draft-1' } }),
|
||||
'infoq-draft-1',
|
||||
)
|
||||
assert.equal(parseInfoQDraftCreation({ code: 0, data: {} }), undefined)
|
||||
assert.deepEqual(
|
||||
inspectInfoQTaskState('https://xie.infoq.cn/draft/write'),
|
||||
{
|
||||
status: 'filled',
|
||||
url: 'https://xie.infoq.cn/draft/write',
|
||||
message: '已打开 InfoQ 新建草稿页,等待创建草稿后填充内容',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectInfoQTaskState('https://xie.infoq.cn/draft/infoq-draft-1', undefined, true),
|
||||
{
|
||||
status: 'draft_saved',
|
||||
url: 'https://xie.infoq.cn/draft/infoq-draft-1',
|
||||
platformWorkId: 'infoq-draft-1',
|
||||
message: '已确认 InfoQ 编辑器显示草稿保存成功',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
inspectInfoQTaskState('https://xie.infoq.cn/article/AbC123'),
|
||||
{
|
||||
status: 'published',
|
||||
url: 'https://xie.infoq.cn/article/AbC123',
|
||||
platformWorkId: 'AbC123',
|
||||
message: '已确认 InfoQ 文章公开地址',
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
inspectInfoQTaskState('https://xie.infoq.cn/draft/list').status,
|
||||
'publication_uncertain',
|
||||
)
|
||||
assert.equal(
|
||||
canReturnExistingPlatformTaskState(
|
||||
inspectInfoQTaskState('https://xie.infoq.cn/draft/infoq-draft-1', undefined, true),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(typeof SYNC_HANDLERS.infoq, 'function')
|
||||
assert.equal(typeof INSPECT_HANDLERS.infoq, 'function')
|
||||
|
||||
const infoqDetectorSource = await readFile(
|
||||
new URL('../distribution/cose/detection/src/platforms/infoq.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.doesNotMatch(infoqDetectorSource, /chrome\.cookies|Cookie\s*:/)
|
||||
|
||||
console.log('Distribution handoff tests passed')
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInteractionCapabilities } from '../interaction/capabilities.js';
|
||||
import {
|
||||
doesDouyinAccountMatch,
|
||||
executeDouyinReplyRequest,
|
||||
executeDouyinSessionRequest,
|
||||
InteractionValidationError,
|
||||
normalizeDouyinRequest,
|
||||
} from '../interaction/douyin.js';
|
||||
|
||||
assert.deepEqual(
|
||||
createInteractionCapabilities('1.4.0').platforms.douyin.comments,
|
||||
{ list: true, replies: true, create: false, manualReply: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeDouyinRequest('listDouyinComments', { workId: '7570305000069549352' }),
|
||||
{ workId: '7570305000069549352', cursor: '0', count: 20 },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeDouyinRequest('replyDouyinComment', {
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
content: ' 感谢你的反馈 ',
|
||||
expectedAccountUid: 'MS4wLjABAAAA_test-account',
|
||||
}),
|
||||
{
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
content: '感谢你的反馈',
|
||||
expectedAccountUid: 'MS4wLjABAAAA_test-account',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(doesDouyinAccountMatch('sec-uid', {
|
||||
uid: 'sec-uid',
|
||||
numericUid: '123',
|
||||
uniqueId: 'account-name',
|
||||
}), true);
|
||||
assert.equal(doesDouyinAccountMatch('another-account', {
|
||||
uid: 'sec-uid',
|
||||
numericUid: '123',
|
||||
uniqueId: 'account-name',
|
||||
}), false);
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeDouyinRequest('listDouyinReplies', {
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
cursor: 20,
|
||||
count: 999,
|
||||
}),
|
||||
{
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
cursor: '20',
|
||||
count: 20,
|
||||
},
|
||||
);
|
||||
|
||||
for (const payload of [
|
||||
{ workId: 'https://example.com/' },
|
||||
{ workId: '7570305000069549352', cursor: '-1' },
|
||||
{ workId: '7570305000069549352', count: 0 },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeDouyinRequest('listDouyinComments', payload),
|
||||
error => error instanceof InteractionValidationError,
|
||||
);
|
||||
}
|
||||
|
||||
for (const payload of [
|
||||
{
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
content: '',
|
||||
expectedAccountUid: 'sec-uid',
|
||||
},
|
||||
{
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
content: 'x'.repeat(501),
|
||||
expectedAccountUid: 'sec-uid',
|
||||
},
|
||||
{
|
||||
workId: '7570305000069549352',
|
||||
commentId: 'not-an-id',
|
||||
content: 'reply',
|
||||
expectedAccountUid: 'sec-uid',
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeDouyinRequest('replyDouyinComment', payload),
|
||||
error => error instanceof InteractionValidationError,
|
||||
);
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
const sessionCalls = [];
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
sessionCalls.push({ url, options });
|
||||
if (url === '/web/api/media/user/info/') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status_code: 0,
|
||||
user: {
|
||||
sec_uid: 'sec-uid',
|
||||
uid: '123456',
|
||||
unique_id: 'aitoearn-test',
|
||||
nickname: 'AiToEarn 测试账号',
|
||||
avatar_thumb: { url_list: ['https://example.com/avatar.png'] },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/web/api/media/anchor/search') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'x-ware-csrf-token': 'challenge,csrf-token-value' }),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected session URL: ${url}`);
|
||||
};
|
||||
|
||||
assert.deepEqual(await executeDouyinSessionRequest({ includeCsrf: true }), {
|
||||
ok: true,
|
||||
result: {
|
||||
account: {
|
||||
uid: 'sec-uid',
|
||||
numericUid: '123456',
|
||||
uniqueId: 'aitoearn-test',
|
||||
nickname: 'AiToEarn 测试账号',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
},
|
||||
csrfToken: 'csrf-token-value',
|
||||
},
|
||||
});
|
||||
assert.equal(sessionCalls.length, 2);
|
||||
assert.equal(sessionCalls[1].options.method, 'HEAD');
|
||||
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Request'], '1');
|
||||
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Version'], '1.2.22');
|
||||
|
||||
const replyCalls = [];
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
replyCalls.push({ url, options });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status_code: 0,
|
||||
comment: { cid: 'new-comment-id' },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
assert.deepEqual(await executeDouyinReplyRequest({
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
content: '感谢你的反馈',
|
||||
csrfToken: 'csrf-token-value',
|
||||
}), {
|
||||
ok: true,
|
||||
result: {
|
||||
platform: 'douyin',
|
||||
workId: '7570305000069549352',
|
||||
parentCommentId: '7570539465648243462',
|
||||
platformCommentId: 'new-comment-id',
|
||||
success: true,
|
||||
source: 'douyin-web',
|
||||
},
|
||||
});
|
||||
assert.equal(replyCalls.length, 1);
|
||||
assert.equal(replyCalls[0].url, '/aweme/v1/web/comment/publish/?aid=6383');
|
||||
assert.equal(replyCalls[0].options.method, 'POST');
|
||||
assert.equal(replyCalls[0].options.headers['x-secsdk-csrf-token'], 'csrf-token-value');
|
||||
assert.equal(replyCalls[0].options.body.get('aweme_id'), '7570305000069549352');
|
||||
assert.equal(replyCalls[0].options.body.get('reply_id'), '7570539465648243462');
|
||||
assert.equal(replyCalls[0].options.body.get('text'), '感谢你的反馈');
|
||||
assert.equal(replyCalls[0].options.body.get('one_level_comment_rank'), '1');
|
||||
|
||||
let failedReplyCallCount = 0;
|
||||
globalThis.fetch = async () => {
|
||||
failedReplyCallCount += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
};
|
||||
};
|
||||
const failedReply = await executeDouyinReplyRequest({
|
||||
workId: '7570305000069549352',
|
||||
commentId: '7570539465648243462',
|
||||
content: '仅发送一次',
|
||||
csrfToken: 'csrf-token-value',
|
||||
});
|
||||
assert.equal(failedReply.ok, false);
|
||||
assert.equal(failedReply.error.code, 'DOUYIN_REPLY_HTTP_ERROR');
|
||||
assert.equal(failedReplyCallCount, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
console.log('Interaction bridge validation passed.');
|
||||
@@ -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