Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef64467f6b | |||
| c2d2a6c749 | |||
| 537df968a0 | |||
| dd9e628ac2 | |||
| 679e949703 | |||
| 984d646e1d | |||
| 52e8f7f4a0 | |||
| da8ad24f14 | |||
| 6561378bc1 | |||
| be650f6fab | |||
| 8a8dcb0023 | |||
| 874455e5f2 | |||
| ae62ff02b6 | |||
| 5821c5711b | |||
| 6aea2f6810 | |||
| 1a94449988 | |||
| 4f454f0ada | |||
| 026b366f50 | |||
| 61cdcc1afb | |||
| e2244a20bf | |||
| 8612e86ba3 | |||
| 77d44bb52f | |||
| 7988fdab53 | |||
| c98c2d3864 | |||
| f6f2c03790 | |||
| 6f55d2c809 | |||
| 1e48681c86 | |||
| fd192bf9ba | |||
| bd963dea72 | |||
| 3c4fbf6310 | |||
| ef620109cf | |||
| 9a5d299a1a |
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.zip binary
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
aitoearn-extension.zip
|
||||
node_modules/
|
||||
@@ -0,0 +1,67 @@
|
||||
# AiToEarn Chrome Extension
|
||||
|
||||
AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打包发布的扩展源码,用于:
|
||||
|
||||
- 从当前网页采集标题、正文和图片。
|
||||
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
|
||||
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
|
||||
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动桥接,不冒充旧官方扩展的完整能力。
|
||||
- 复用浏览器现有登录状态检测内容平台账号。
|
||||
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
|
||||
- 扩展弹窗从 COSE 适配器动态读取已完成受控填充验证的平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
|
||||
|
||||
## Browser Provider
|
||||
|
||||
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入 CSDN、掘金、简书、知乎、今日头条、百家号、网易号、搜狐号、InfoQ、腾讯云开发者社区、阿里云开发者社区、思否、51CTO、开源中国和腾讯内容开放平台(企鹅号):扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条、百家号、网易号、搜狐号和 InfoQ 会将 Markdown 转为安全的富文本,保留标题、段落、列表、链接和图片。CSDN 只使用编辑器自带的签名请求客户端读取创作者资料,思否只从当前页面的 Next.js 会话数据读取 UID;网易号只接受创作者侧返回的 `wemediaId`,搜狐号只接受账号列表中的账号 ID,InfoQ 只接受用户接口返回的 UID,51CTO 只接受个人空间页面中可验证的稳定 UID,开源中国只接受官方账号接口返回的稳定 UID,企鹅号只接受页面中的稳定 `userID` / `userId`。
|
||||
|
||||
扩展支持服务端随发布任务下发经过签名安装、Schema 校验并固化版本身份的声明式 Browser Provider 快照。插件只能覆盖核心十五个平台的标题、正文与第一层草稿/发布按钮定义;平台 URL、账号检测和最终结果判断仍由扩展核心维护。扩展会再次严格校验平台、选择器、编辑器类型、内容格式、源提交和内容哈希,写入后回读标题、正文长度和图片数量。配置漂移或回读失败时保留编辑器并返回明确失败,不执行仓库代码,也不会把未知按钮当作核心动作继续点击。
|
||||
|
||||
Browser Provider 仅负责账号核对、编辑器填充和草稿/公开文章状态回读,不会自动点击最终公开发布按钮。所有已接入平台仍保留复制发布稿入口;未进入受控交接白名单的平台继续只支持手动复制,不会伪报“已同步”。
|
||||
|
||||
扩展弹窗还保留公众号的单次人工确认填充入口,但不把它伪装成 Browser Provider。其他旧 COSE 选择器不会出现在扩展平台列表中,也不能通过桥接接口绕过能力白名单;仅有选择器或静态平台配置不代表平台已经接入。
|
||||
|
||||
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。CSDN 只有 `blog.csdn.net/:author/article/details/:id`、掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id` 或 `www.toutiao.com/item/:id`、百家号只有 `baijiahao.baidu.com/s?id=:id`、网易号只有 `www.163.com/dy/article/:id.html`、搜狐号只有 `www.sohu.com/a/:id`、InfoQ 只有 `xie.infoq.cn/article/:id` 或 `www.infoq.cn/article/:id`、腾讯云只有 `cloud.tencent.com/developer/article/:id`、阿里云只有 `developer.aliyun.com/article/:id`、思否只有 `segmentfault.com/a/:id`、51CTO 只有 `blog.51cto.com/u_:uid/:id` 才记为已发布;带草稿标识的编辑器地址,以及网易号、搜狐号、InfoQ、51CTO 或开源中国页面明确提示草稿保存成功时才记为草稿。开源中国的公开文章 URL 尚未在真实创作者账号上验收,因此未知页面会进入“发布结果待确认”,不会伪报成功。
|
||||
|
||||
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
|
||||
|
||||
扩展弹窗和后台通知中的“成功”只表示平台编辑器已打开并完成内容填充,不表示内容已经公开发布。右键菜单“提取此页面到 AiToEarn”会把最近一次提取结果暂存到浏览器本地,随后打开扩展即可继续选择平台。
|
||||
|
||||
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
|
||||
|
||||
当前互动桥接只开放受控白名单:能力查询、抖音账号核对、评论列表、回复列表和单条人工回复。作品 ID、评论 ID、账号 UID、回复内容、游标和数量均由扩展校验,网页不能向扩展传入任意请求地址。发送回复前必须由用户在 AiToEarn 界面逐条确认,扩展会再次核对当前浏览器账号与所选 AiToEarn 账号,写请求不会自动重试,也不支持批量回复、自动回复或私信。扩展优先复用当前 Chrome 中已有的抖音标签页,没有可复用标签页时才创建非激活标签页,并在抖音页面上下文内发起带现有 Cookie 的请求。
|
||||
|
||||
## 上游来源
|
||||
|
||||
多平台检测与编辑器填充逻辑基于 `doocs/cose`,固定版本和许可证见:
|
||||
|
||||
- `distribution/cose/UPSTREAM.md`
|
||||
- `distribution/cose/LICENSE`
|
||||
|
||||
AiToEarn 自行维护网页桥接、载荷协议、产品界面、打包和发布流程。
|
||||
|
||||
## 本地安装
|
||||
|
||||
1. 从 Gitea Release 下载 `aitoearn-extension-v<version>.zip`。
|
||||
2. 将 ZIP 解压到一个固定目录,不要直接在压缩包内打开文件。
|
||||
3. 打开 `chrome://extensions` 或 `edge://extensions`。
|
||||
4. 开启“开发者模式”。
|
||||
5. 点击“加载已解压的扩展程序”,选择包含 `manifest.json` 的解压目录。
|
||||
6. 回到 AiToEarn 页面并刷新,确认扩展状态和平台登录状态。
|
||||
|
||||
更新时,先备份当前加载目录,再用新 ZIP 中的文件替换该目录内容,然后在扩展管理页点击“重新加载”。如原目录已丢失,移除失效记录后将新 ZIP 解压到固定目录,再重新“加载已解压的扩展程序”。
|
||||
|
||||
## 打包
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\package.ps1
|
||||
```
|
||||
|
||||
脚本会校验 `manifest.json`、全部 JavaScript 语法、必需文件、互动桥接、分发桥接和 Browser Agent Provider 快照执行链,再输出:
|
||||
|
||||
```text
|
||||
dist/aitoearn-extension-v<version>.zip
|
||||
```
|
||||
|
||||
ZIP 根目录直接包含 `manifest.json`,可以解压后加载。
|
||||
Binary file not shown.
+232
-29
@@ -1,38 +1,241 @@
|
||||
// AiToEarn Background Service Worker
|
||||
import './distribution/cose/background.js';
|
||||
import './interaction/background.js';
|
||||
import './distribution/browser-agent/index.js';
|
||||
import { AUTO_FILL_PLATFORM_IDS } from './distribution/cose/core/platforms/index.js';
|
||||
|
||||
const PREPARE_BATCH_MESSAGE = 'PREPARE_PLATFORM_BATCH';
|
||||
const CAPTURE_URL_MESSAGE = 'CAPTURE_URL';
|
||||
const CAPTURE_ATTEMPTS = 8;
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function normalizeCaptureUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('只支持采集 HTTP 或 HTTPS 页面');
|
||||
if (url.username || url.password) throw new Error('采集地址不能包含账号或密码');
|
||||
url.hash = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function comparableUrl(value) {
|
||||
try {
|
||||
return normalizeCaptureUrl(value).href.replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error('页面加载超时');
|
||||
}
|
||||
|
||||
async function requestPageExtraction(tabId) {
|
||||
try {
|
||||
return await chrome.tabs.sendMessage(tabId, { action: 'extractContent' });
|
||||
} catch {
|
||||
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] });
|
||||
return chrome.tabs.sendMessage(tabId, { action: 'extractContent' });
|
||||
}
|
||||
}
|
||||
|
||||
async function captureUrl(rawUrl) {
|
||||
const requestedUrl = normalizeCaptureUrl(rawUrl);
|
||||
const requestedComparableUrl = comparableUrl(requestedUrl.href);
|
||||
const tabs = await chrome.tabs.query({});
|
||||
let tab = tabs.find(candidate => candidate.id && comparableUrl(candidate.url) === requestedComparableUrl);
|
||||
const createdTab = !tab;
|
||||
let keepTabOpen = false;
|
||||
|
||||
if (!tab) {
|
||||
tab = await chrome.tabs.create({ url: requestedUrl.href, active: false });
|
||||
}
|
||||
if (!tab.id) throw new Error('无法创建或复用采集标签页');
|
||||
|
||||
try {
|
||||
await waitForTabReady(tab.id);
|
||||
let lastBodyLength = 0;
|
||||
let lastTitle = '';
|
||||
|
||||
for (let attempt = 1; attempt <= CAPTURE_ATTEMPTS; attempt += 1) {
|
||||
const response = await requestPageExtraction(tab.id);
|
||||
if (!response?.success) throw new Error(response?.error || '扩展无法读取页面内容');
|
||||
|
||||
const data = response.data || {};
|
||||
const accessState = data.accessState || { status: 'ok', message: '', requiresUserAction: false };
|
||||
if (accessState.requiresUserAction) {
|
||||
keepTabOpen = true;
|
||||
await chrome.tabs.update(tab.id, { active: true });
|
||||
return {
|
||||
success: false,
|
||||
error: accessState.message || '页面需要人工操作后才能继续采集',
|
||||
requiresUserAction: true,
|
||||
accessState,
|
||||
tabId: tab.id,
|
||||
reusedTab: !createdTab,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyLength = typeof data.body === 'string' ? data.body.trim().length : 0;
|
||||
const minimumLength = data.pageType === 'article' ? 120 : 60;
|
||||
lastBodyLength = bodyLength;
|
||||
lastTitle = data.title || '';
|
||||
if (bodyLength >= minimumLength) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...data,
|
||||
url: requestedUrl.href,
|
||||
finalUrl: data.url || requestedUrl.href,
|
||||
},
|
||||
tabId: tab.id,
|
||||
reusedTab: !createdTab,
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(Math.min(2500, 600 + attempt * 250));
|
||||
}
|
||||
|
||||
throw new Error(`页面已打开,但动态正文仍未渲染完成(标题:${lastTitle || '未知'},正文 ${lastBodyLength} 字)`);
|
||||
} finally {
|
||||
if (createdTab && !keepTabOpen) {
|
||||
await chrome.tabs.remove(tab.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePlatformBatch(platformIds, content) {
|
||||
await chrome.runtime.sendMessage({ type: 'START_SYNC_BATCH' });
|
||||
|
||||
const results = [];
|
||||
for (const platformId of platformIds) {
|
||||
try {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'SYNC_TO_PLATFORM',
|
||||
platformId,
|
||||
content,
|
||||
});
|
||||
const success = Boolean(result) && result.success !== false && !result.error;
|
||||
results.push({
|
||||
platformId,
|
||||
success,
|
||||
message: result?.message || '',
|
||||
error: result?.error || '',
|
||||
tabId: result?.tabId,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
platformId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(result => result.success).length;
|
||||
const total = platformIds.length;
|
||||
const message = successCount > 0
|
||||
? `已打开并填充 ${successCount}/${total} 个平台,请逐个平台检查后手动确认发布。`
|
||||
: `未能填充所选的 ${total} 个平台,请检查登录状态后重试。`;
|
||||
|
||||
await chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'AiToEarn 内容分发',
|
||||
message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: successCount > 0,
|
||||
successCount,
|
||||
total,
|
||||
message,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: 'aitoearn-publish',
|
||||
title: '用AiToEarn发布此页面',
|
||||
contexts: ['page', 'link'],
|
||||
});
|
||||
chrome.storage.sync.get(['apiUrl'], (result) => {
|
||||
if (!result.apiUrl) {
|
||||
chrome.storage.sync.set({ apiUrl: 'http://localhost:3002' });
|
||||
}
|
||||
chrome.contextMenus.removeAll(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: 'aitoearn-extract',
|
||||
title: '提取此页面到 AiToEarn',
|
||||
contexts: ['page', 'link'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
if (info.menuItemId === 'aitoearn-publish' && tab) {
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'extractContent' }, (response) => {
|
||||
if (response && response.success) {
|
||||
const data = response.data;
|
||||
chrome.storage.sync.get(['apiKey'], (result) => {
|
||||
const key = result.apiKey || '';
|
||||
const text = '标题: ' + data.title + '\n\n' + data.bodyText.substring(0, 500);
|
||||
chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.svg',
|
||||
title: 'AiToEarn - 页面内容已提取',
|
||||
message: '标题: ' + data.title.substring(0, 50) + ' | ' + data.images.length + ' 张图片',
|
||||
});
|
||||
});
|
||||
}
|
||||
if (info.menuItemId !== 'aitoearn-extract' || !tab?.id) return;
|
||||
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'extractContent' }, (response) => {
|
||||
if (!response?.success) return;
|
||||
|
||||
const data = response.data;
|
||||
chrome.storage.local.set({
|
||||
pendingExtract: {
|
||||
...data,
|
||||
extractedAt: Date.now(),
|
||||
},
|
||||
}).then(() => {
|
||||
chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'AiToEarn 已提取页面',
|
||||
message: `已提取“${data.title.substring(0, 50)}”,打开扩展即可选择平台。`,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
chrome.action.onClicked.addListener((tab) => {
|
||||
// popup handles this, no-op
|
||||
});
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.type === CAPTURE_URL_MESSAGE) {
|
||||
if (typeof request.url !== 'string' || !request.url.trim()) {
|
||||
sendResponse({ success: false, error: '缺少要采集的页面地址' });
|
||||
return false;
|
||||
}
|
||||
captureUrl(request.url)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.type !== PREPARE_BATCH_MESSAGE) return false;
|
||||
|
||||
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: requestedPlatformIds.length > 0
|
||||
? '所选平台暂未开放受控自动填充,请使用对应平台的复制发布稿。'
|
||||
: '缺少目标平台或分发内容',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
preparePlatformBatch(platformIds, request.content)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
+113
-42
@@ -3,17 +3,47 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (globalThis.__AI_TO_EARN_CONTENT_SCRIPT__) return;
|
||||
globalThis.__AI_TO_EARN_CONTENT_SCRIPT__ = true;
|
||||
|
||||
const contentSelectors = [
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'main',
|
||||
'.amos-land-page',
|
||||
'.post-content',
|
||||
'.article-content',
|
||||
'.entry-content',
|
||||
'.content-body',
|
||||
'.markdown-body',
|
||||
'.rich_media_content',
|
||||
'#article-content',
|
||||
'#content',
|
||||
'.content',
|
||||
'.post',
|
||||
'.article',
|
||||
'.story-body',
|
||||
'#cnblogs_post_body',
|
||||
'.htmledit_views',
|
||||
'#article_content',
|
||||
'.blog-content-box'
|
||||
];
|
||||
|
||||
// ===== Content Extraction =====
|
||||
function extractPageContent() {
|
||||
const data = {
|
||||
title: '',
|
||||
description: '',
|
||||
body: '',
|
||||
html: '',
|
||||
images: [],
|
||||
links: [],
|
||||
url: window.location.href,
|
||||
siteName: '',
|
||||
author: '',
|
||||
publishDate: ''
|
||||
publishDate: '',
|
||||
pageType: 'article',
|
||||
accessState: { status: 'ok', message: '', requiresUserAction: false }
|
||||
};
|
||||
|
||||
// --- Title ---
|
||||
@@ -43,49 +73,32 @@
|
||||
|| '';
|
||||
|
||||
// --- Body Content ---
|
||||
data.body = extractMainContent();
|
||||
const mainContent = extractMainContent();
|
||||
data.body = mainContent.text.slice(0, 100000);
|
||||
data.html = mainContent.html.slice(0, 900000);
|
||||
|
||||
// --- Images ---
|
||||
data.images = extractImages();
|
||||
data.images = extractImages(mainContent.element);
|
||||
data.links = extractLinks(mainContent.element);
|
||||
data.pageType = detectPageType();
|
||||
data.accessState = detectAccessState(data.body);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getMetaContent(name) {
|
||||
const el = document.querySelector(meta[property=""], meta[name=""]);
|
||||
const el = document.querySelector(`meta[property="${name}"], meta[name="${name}"]`);
|
||||
return el?.content || '';
|
||||
}
|
||||
|
||||
// ===== Main Content Extraction =====
|
||||
function extractMainContent() {
|
||||
// Priority selectors for main content areas
|
||||
const contentSelectors = [
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'main',
|
||||
'.post-content',
|
||||
'.article-content',
|
||||
'.entry-content',
|
||||
'.content-body',
|
||||
'.markdown-body',
|
||||
'#article-content',
|
||||
'#content',
|
||||
'.content',
|
||||
'.post',
|
||||
'.article',
|
||||
'.story-body',
|
||||
'#cnblogs_post_body',
|
||||
'.htmledit_views',
|
||||
'#article_content',
|
||||
'.blog-content-box'
|
||||
];
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
const text = cleanText(el.innerText);
|
||||
if (text.length > 50) {
|
||||
return text;
|
||||
return { text, html: el.outerHTML, element: el };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,16 +110,66 @@
|
||||
.map(p => p.textContent.trim())
|
||||
.filter(t => t.length > 20);
|
||||
if (texts.length > 0) {
|
||||
return cleanText(texts.join('\n\n'));
|
||||
return {
|
||||
text: cleanText(texts.join('\n\n')),
|
||||
html: `<main>${Array.from(paragraphs).map(p => p.outerHTML).join('')}</main>`,
|
||||
element: document.body
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return cleanText(document.body.innerText);
|
||||
return {
|
||||
text: cleanText(document.body?.innerText || ''),
|
||||
html: document.body?.innerHTML || '',
|
||||
element: document.body
|
||||
};
|
||||
}
|
||||
|
||||
function detectPageType() {
|
||||
const route = `${window.location.pathname}${window.location.search}`.toLowerCase();
|
||||
if (/\/trending\/|\/topic(?:\/|$)|topic_innerflow/.test(route)
|
||||
|| document.querySelector('.amos-land-page, [data-page-type="topic"]')) {
|
||||
return 'topic';
|
||||
}
|
||||
if (/\/(?:search|tag|category|hot|feed)(?:\/|$)/.test(window.location.pathname.toLowerCase())
|
||||
|| document.querySelector('[data-page-type="listing"]')) {
|
||||
return 'listing';
|
||||
}
|
||||
return 'article';
|
||||
}
|
||||
|
||||
function detectAccessState(extractedBody) {
|
||||
const visible = element => Boolean(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length));
|
||||
const pageText = cleanText(document.body?.innerText || '').slice(0, 12000);
|
||||
const lowerText = pageText.toLowerCase();
|
||||
const captchaElement = Array.from(document.querySelectorAll(
|
||||
'[class*="captcha"], [id*="captcha"], [class*="verify"], [id*="verify"], iframe[src*="captcha"]'
|
||||
)).find(visible);
|
||||
if (captchaElement || /(验证码|安全验证|人机验证|滑块验证|verify you are human|complete the security check|captcha)/i.test(pageText)) {
|
||||
return { status: 'captcha_required', message: '页面需要完成验证码或安全验证', requiresUserAction: true };
|
||||
}
|
||||
|
||||
const passwordInput = Array.from(document.querySelectorAll('input[type="password"]')).find(visible);
|
||||
const loginRoute = /\/(?:login|signin|passport)(?:\/|$)/i.test(window.location.pathname);
|
||||
if (passwordInput || (loginRoute && /(登录|登陆|sign in|log in)/i.test(pageText))) {
|
||||
return { status: 'login_required', message: '页面需要登录后才能采集', requiresUserAction: true };
|
||||
}
|
||||
|
||||
if (extractedBody.length < 120 && (
|
||||
lowerText.includes('access denied')
|
||||
|| lowerText.includes('forbidden')
|
||||
|| lowerText.includes('not available in your region')
|
||||
|| /(所在地区|当前地区|地区限制|需要.*vpn|网络环境受限)/i.test(pageText)
|
||||
)) {
|
||||
return { status: 'access_restricted', message: '页面存在地区或网络访问限制', requiresUserAction: true };
|
||||
}
|
||||
|
||||
return { status: 'ok', message: '', requiresUserAction: false };
|
||||
}
|
||||
|
||||
// ===== Image Extraction =====
|
||||
function extractImages() {
|
||||
function extractImages(mainElement) {
|
||||
const images = new Set();
|
||||
|
||||
// 1. Open Graph image
|
||||
@@ -121,18 +184,17 @@
|
||||
// Skip favicons, they're not useful as content images
|
||||
|
||||
// 4. Content area images (from article/main/content areas)
|
||||
const contentSelectors = ['article', 'main', '.content', '.post-content', '#content'];
|
||||
for (const selector of contentSelectors) {
|
||||
const container = document.querySelector(selector);
|
||||
if (container) {
|
||||
container.querySelectorAll('img').forEach(img => {
|
||||
const src = img.src || img.dataset.src || img.dataset.original;
|
||||
if (src && !isSmallImage(img) && !isIconOrAvatar(src)) {
|
||||
images.add(resolveUrl(src));
|
||||
}
|
||||
});
|
||||
if (images.size > 0) break;
|
||||
}
|
||||
if (mainElement) {
|
||||
mainElement.querySelectorAll('img').forEach(img => {
|
||||
const src = img.currentSrc
|
||||
|| img.src
|
||||
|| img.dataset.src
|
||||
|| img.dataset.original
|
||||
|| img.dataset.lazySrc;
|
||||
if (src && !isSmallImage(img) && !isIconOrAvatar(src)) {
|
||||
images.add(resolveUrl(src));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. If no content images found, scan all images
|
||||
@@ -148,6 +210,15 @@
|
||||
return Array.from(images).slice(0, 10);
|
||||
}
|
||||
|
||||
function extractLinks(mainElement) {
|
||||
const links = new Set();
|
||||
(mainElement || document.body)?.querySelectorAll('a[href]').forEach(anchor => {
|
||||
const href = resolveUrl(anchor.getAttribute('href'));
|
||||
if (/^https?:\/\//i.test(href)) links.add(href);
|
||||
});
|
||||
return Array.from(links).slice(0, 100);
|
||||
}
|
||||
|
||||
function isSmallImage(img) {
|
||||
return (img.naturalWidth > 0 && img.naturalWidth < 100) ||
|
||||
(img.naturalHeight > 0 && img.naturalHeight < 100);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
(function bridgeAiToEarnDistribution() {
|
||||
const requestSource = 'aitoearn-web';
|
||||
const responseSource = 'aitoearn-extension';
|
||||
const allowedMethods = new Set([
|
||||
'getVersion',
|
||||
'listPlatforms',
|
||||
'checkPlatforms',
|
||||
'getPlatformAccount',
|
||||
'captureUrl',
|
||||
'startBatch',
|
||||
'syncToPlatform',
|
||||
'openPlatformTask',
|
||||
'inspectPlatformTask',
|
||||
'pairBrowserAgent',
|
||||
'getBrowserAgentStatus',
|
||||
'pollBrowserAgentNow',
|
||||
'disconnectBrowserAgent',
|
||||
]);
|
||||
|
||||
async function sendRuntimeMessage(message) {
|
||||
const response = await chrome.runtime.sendMessage(message);
|
||||
if (response?.error) throw new Error(response.error);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function listPlatforms() {
|
||||
const response = await sendRuntimeMessage({ type: 'GET_PLATFORMS' });
|
||||
return Array.isArray(response?.platforms) ? response.platforms : [];
|
||||
}
|
||||
|
||||
async function handle(method, payload) {
|
||||
switch (method) {
|
||||
case 'getVersion':
|
||||
return { version: chrome.runtime.getManifest().version, source: 'aitoearn-cose' };
|
||||
case 'listPlatforms':
|
||||
return listPlatforms();
|
||||
case 'checkPlatforms': {
|
||||
const platforms = await listPlatforms();
|
||||
const requested = Array.isArray(payload?.platformIds) ? new Set(payload.platformIds) : null;
|
||||
const selected = requested ? platforms.filter(platform => requested.has(platform.id)) : platforms;
|
||||
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('缺少要采集的页面地址');
|
||||
return chrome.runtime.sendMessage({ type: 'CAPTURE_URL', url: payload.url });
|
||||
}
|
||||
case 'startBatch':
|
||||
return sendRuntimeMessage({ type: 'START_SYNC_BATCH' });
|
||||
case 'syncToPlatform': {
|
||||
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
|
||||
throw new Error('缺少目标平台');
|
||||
if (!payload.content || typeof payload.content !== 'object')
|
||||
throw new Error('缺少分发内容');
|
||||
return sendRuntimeMessage({
|
||||
type: 'SYNC_TO_PLATFORM',
|
||||
platformId: payload.platformId,
|
||||
content: payload.content,
|
||||
});
|
||||
}
|
||||
case 'openPlatformTask': {
|
||||
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
|
||||
throw new Error('缺少目标平台');
|
||||
if (!payload.content || typeof payload.content !== 'object')
|
||||
throw new Error('缺少分发内容');
|
||||
return sendRuntimeMessage({
|
||||
type: 'OPEN_PLATFORM_TASK',
|
||||
platformId: payload.platformId,
|
||||
expectedPlatformUid: payload.expectedPlatformUid,
|
||||
tabId: payload.tabId,
|
||||
content: payload.content,
|
||||
});
|
||||
}
|
||||
case 'inspectPlatformTask': {
|
||||
if (typeof payload?.platformId !== 'string' || !payload.platformId.trim())
|
||||
throw new Error('缺少目标平台');
|
||||
if (!Number.isInteger(payload?.tabId) || payload.tabId <= 0)
|
||||
throw new Error('缺少有效的平台编辑器标签页');
|
||||
return sendRuntimeMessage({
|
||||
type: 'INSPECT_PLATFORM_TASK',
|
||||
platformId: payload.platformId,
|
||||
tabId: payload.tabId,
|
||||
expectedPlatformUid: payload.expectedPlatformUid,
|
||||
});
|
||||
}
|
||||
case 'pairBrowserAgent':
|
||||
return sendRuntimeMessage({
|
||||
type: 'PAIR_BROWSER_AGENT',
|
||||
payload,
|
||||
});
|
||||
case 'getBrowserAgentStatus':
|
||||
return sendRuntimeMessage({ type: 'GET_BROWSER_AGENT_STATUS' });
|
||||
case 'pollBrowserAgentNow':
|
||||
return sendRuntimeMessage({ type: 'POLL_BROWSER_AGENT_NOW' });
|
||||
case 'disconnectBrowserAgent':
|
||||
return sendRuntimeMessage({ type: 'DISCONNECT_BROWSER_AGENT' });
|
||||
default:
|
||||
throw new Error(`不支持的分发方法: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (event.source !== window || 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 }, '*');
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
source: responseSource,
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,70 @@
|
||||
(function installAiToEarnDistributionBridge() {
|
||||
if (window.AIToEarnDistribution) return;
|
||||
|
||||
const requestSource = 'aitoearn-web';
|
||||
const responseSource = 'aitoearn-extension';
|
||||
const pending = new Map();
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window || event.data?.source !== responseSource) return;
|
||||
const entry = pending.get(event.data.requestId);
|
||||
if (!entry) return;
|
||||
pending.delete(event.data.requestId);
|
||||
if (event.data.error) entry.reject(new Error(event.data.error));
|
||||
else entry.resolve(event.data.result);
|
||||
});
|
||||
|
||||
function request(method, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const timeout = window.setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
reject(new Error('AiToEarn 分发扩展响应超时'));
|
||||
}, 120000);
|
||||
|
||||
pending.set(requestId, {
|
||||
resolve(value) {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
reject(error) {
|
||||
window.clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
window.postMessage({ source: requestSource, requestId, method, payload }, '*');
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'AIToEarnDistribution', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: {
|
||||
getVersion: () => request('getVersion'),
|
||||
listPlatforms: () => request('listPlatforms'),
|
||||
checkPlatforms: platformIds => request('checkPlatforms', { platformIds }),
|
||||
getPlatformAccount: platformId => request('getPlatformAccount', { platformId }),
|
||||
captureUrl: url => request('captureUrl', { url }),
|
||||
startBatch: () => request('startBatch'),
|
||||
syncToPlatform: (platformId, content) => request('syncToPlatform', { platformId, content }),
|
||||
openPlatformTask: (platformId, content, expectedPlatformUid, tabId) => request('openPlatformTask', {
|
||||
platformId,
|
||||
content,
|
||||
expectedPlatformUid,
|
||||
tabId,
|
||||
}),
|
||||
inspectPlatformTask: (platformId, tabId, expectedPlatformUid) => request('inspectPlatformTask', {
|
||||
platformId,
|
||||
tabId,
|
||||
expectedPlatformUid,
|
||||
}),
|
||||
pairBrowserAgent: payload => request('pairBrowserAgent', payload),
|
||||
getBrowserAgentStatus: () => request('getBrowserAgentStatus'),
|
||||
pollBrowserAgentNow: () => request('pollBrowserAgentNow'),
|
||||
disconnectBrowserAgent: () => request('disconnectBrowserAgent'),
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('aitoearn:distribution-ready'));
|
||||
})();
|
||||
@@ -0,0 +1,728 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from '../cose/core/platforms/toutiao.js'
|
||||
import { AUTO_FILL_PLATFORM_IDS } from '../cose/core/platforms/index.js'
|
||||
import {
|
||||
getDeclarativeProviderAction,
|
||||
validateBrowserProviderSnapshot,
|
||||
} from '../cose/core/declarative-browser-provider.js'
|
||||
|
||||
const ALLOWED_API_ORIGINS = new Set([
|
||||
'https://wxapi.frp.it1024.cc',
|
||||
'http://localhost:3002',
|
||||
])
|
||||
|
||||
const BROWSER_AGENT_STORAGE_KEY = 'aitoearn_browser_agent_v1'
|
||||
const BROWSER_AGENT_ALARM_NAME = 'aitoearn-browser-agent-poll'
|
||||
const BROWSER_AGENT_PLATFORM_IDS = [...AUTO_FILL_PLATFORM_IDS]
|
||||
.filter(platformId => platformId !== 'wechat')
|
||||
|
||||
const PLATFORM_TAB_PATTERNS = {
|
||||
csdn: ['https://editor.csdn.net/md/*'],
|
||||
juejin: ['https://juejin.cn/*'],
|
||||
jianshu: ['https://www.jianshu.com/*'],
|
||||
zhihu: ['https://zhuanlan.zhihu.com/*', 'https://www.zhihu.com/*'],
|
||||
toutiao: ['https://mp.toutiao.com/*'],
|
||||
baijiahao: ['https://baijiahao.baidu.com/*'],
|
||||
wangyi: ['https://mp.163.com/*'],
|
||||
sohu: ['https://mp.sohu.com/*'],
|
||||
infoq: ['https://xie.infoq.cn/*'],
|
||||
tencentcloud: ['https://cloud.tencent.com/developer/*'],
|
||||
aliyun: ['https://developer.aliyun.com/*'],
|
||||
segmentfault: ['https://segmentfault.com/*'],
|
||||
'51cto': ['https://blog.51cto.com/*'],
|
||||
oschina: ['https://my.oschina.net/*'],
|
||||
qiehao: ['https://om.qq.com/*'],
|
||||
}
|
||||
|
||||
const PLATFORM_ACTIONS = {
|
||||
csdn: {
|
||||
draft: { selector: 'button.btn.btn-save', text: '保存草稿' },
|
||||
publish: { selector: 'button.btn.btn-publish', text: '发布文章' },
|
||||
},
|
||||
tencentcloud: {
|
||||
draft: { selector: 'button.cdc-btn.cdc-btn--hole', text: '存草稿' },
|
||||
publish: { selector: 'button.cdc-btn.cdc-btn--primary', text: '去发布' },
|
||||
},
|
||||
aliyun: {
|
||||
draft: { selector: 'button.public-button.gray-button', text: '存为草稿' },
|
||||
publish: { selector: 'button.public-button.orange-button', text: '发布文章' },
|
||||
},
|
||||
qiehao: {
|
||||
draft: { selector: 'button.omui-button.omui-button--background-grey', text: '存草稿' },
|
||||
publish: { selector: 'button.omui-button.omui-button--primary', text: '发布' },
|
||||
},
|
||||
}
|
||||
|
||||
let pollingPromise = null
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
const rawValue = typeof value === 'string' && value.trim()
|
||||
? value.trim()
|
||||
: 'https://wxapi.frp.it1024.cc'
|
||||
const url = new URL(rawValue)
|
||||
if (url.pathname !== '/' || url.search || url.hash || !ALLOWED_API_ORIGINS.has(url.origin)) {
|
||||
throw new Error('浏览器 Agent 只允许连接已配置的 AiToEarn API 地址')
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function getPlatformAction(platformId, publishMode, providerConfig) {
|
||||
if (providerConfig !== undefined) {
|
||||
return getDeclarativeProviderAction(providerConfig, platformId, publishMode)
|
||||
}
|
||||
return PLATFORM_ACTIONS[platformId]?.[publishMode] || null
|
||||
}
|
||||
|
||||
function getTaskExecutionPolicy(platformId, publishMode, openedStatus, providerConfig) {
|
||||
if (openedStatus === 'published') return 'report_published'
|
||||
|
||||
const action = getPlatformAction(platformId, publishMode, providerConfig)
|
||||
if (openedStatus === 'draft_saved' && (publishMode === 'draft' || !action)) {
|
||||
return 'report_draft_saved'
|
||||
}
|
||||
if (action) return 'perform_action'
|
||||
if (openedStatus === 'publication_uncertain') return 'report_uncertain'
|
||||
return 'report_filled'
|
||||
}
|
||||
|
||||
function getExtensionVersion() {
|
||||
return chrome.runtime.getManifest().version
|
||||
}
|
||||
|
||||
function getMessage(error, fallback) {
|
||||
if (error instanceof Error && error.message) return error.message.slice(0, 1000)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function unwrapApiPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') return payload
|
||||
if (typeof payload.code === 'number' && payload.code !== 0) {
|
||||
throw new Error(typeof payload.message === 'string' ? payload.message : '浏览器 Agent 请求失败')
|
||||
}
|
||||
return payload.data === undefined ? payload : payload.data
|
||||
}
|
||||
|
||||
async function requestBrowserAgent(agent, path, init = {}) {
|
||||
const headers = new Headers(init.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
if (agent?.token) headers.set('x-aitoearn-browser-agent', agent.token)
|
||||
if (init.body !== undefined) headers.set('Content-Type', 'application/json')
|
||||
|
||||
const response = await fetch(agent.apiBaseUrl + path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
let payload
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new Error('AiToEarn API 返回了无效响应')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = payload && typeof payload === 'object' && typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '浏览器 Agent 请求失败:' + response.status
|
||||
throw new Error(message)
|
||||
}
|
||||
return unwrapApiPayload(payload)
|
||||
}
|
||||
|
||||
async function getStoredBrowserAgent() {
|
||||
const result = await chrome.storage.local.get(BROWSER_AGENT_STORAGE_KEY)
|
||||
const stored = result?.[BROWSER_AGENT_STORAGE_KEY]
|
||||
if (
|
||||
!stored
|
||||
|| typeof stored !== 'object'
|
||||
|| typeof stored.agentId !== 'string'
|
||||
|| typeof stored.token !== 'string'
|
||||
|| typeof stored.apiBaseUrl !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
async function saveStoredBrowserAgent(agent) {
|
||||
await chrome.storage.local.set({
|
||||
[BROWSER_AGENT_STORAGE_KEY]: agent,
|
||||
})
|
||||
}
|
||||
|
||||
function toPublicAgentState(agent, extra = {}) {
|
||||
if (!agent) {
|
||||
return {
|
||||
paired: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
return {
|
||||
paired: true,
|
||||
agentId: agent.agentId,
|
||||
apiBaseUrl: agent.apiBaseUrl,
|
||||
pairedAt: agent.pairedAt,
|
||||
runtime: agent.runtime || null,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRuntimeState(agent, runtime) {
|
||||
const next = {
|
||||
...agent,
|
||||
runtime: {
|
||||
...agent.runtime,
|
||||
...runtime,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
await saveStoredBrowserAgent(next)
|
||||
return next
|
||||
}
|
||||
|
||||
async function syncPollingAlarm() {
|
||||
await chrome.alarms.create(BROWSER_AGENT_ALARM_NAME, {
|
||||
periodInMinutes: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async function pairBrowserAgent(payload) {
|
||||
if (!payload || typeof payload !== 'object') throw new Error('缺少浏览器扩展配对信息')
|
||||
if (typeof payload.agentId !== 'string' || !payload.agentId.trim()) throw new Error('缺少浏览器 Agent ID')
|
||||
if (typeof payload.enrollmentCode !== 'string' || payload.enrollmentCode.length < 32) {
|
||||
throw new Error('浏览器扩展配对码无效')
|
||||
}
|
||||
|
||||
const apiBaseUrl = normalizeApiBaseUrl(payload.apiBaseUrl)
|
||||
const exchange = await requestBrowserAgent(
|
||||
{ apiBaseUrl },
|
||||
'/v2/channels/browser-agents/enroll',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agentId: payload.agentId,
|
||||
enrollmentCode: payload.enrollmentCode,
|
||||
extensionVersion: getExtensionVersion(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!exchange || typeof exchange.token !== 'string' || typeof exchange.agentId !== 'string') {
|
||||
throw new Error('浏览器 Agent 配对响应无效')
|
||||
}
|
||||
|
||||
const agent = {
|
||||
agentId: exchange.agentId,
|
||||
token: exchange.token,
|
||||
apiBaseUrl,
|
||||
pairedAt: new Date().toISOString(),
|
||||
runtime: {
|
||||
status: 'paired',
|
||||
message: '浏览器 Agent 已配对,等待可执行任务',
|
||||
},
|
||||
}
|
||||
await saveStoredBrowserAgent(agent)
|
||||
await syncPollingAlarm()
|
||||
return toPublicAgentState(agent)
|
||||
}
|
||||
|
||||
async function getBrowserAgentStatus() {
|
||||
const agent = await getStoredBrowserAgent()
|
||||
if (!agent) return toPublicAgentState(null)
|
||||
|
||||
try {
|
||||
const session = await requestBrowserAgent(agent, '/v2/channels/browser-agents/session/current')
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'online',
|
||||
session,
|
||||
lastError: undefined,
|
||||
})
|
||||
return toPublicAgentState(next, { session })
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'offline',
|
||||
lastError: getMessage(error, '无法连接浏览器 Agent 服务'),
|
||||
})
|
||||
return toPublicAgentState(next)
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectBrowserAgent() {
|
||||
await chrome.storage.local.remove(BROWSER_AGENT_STORAGE_KEY)
|
||||
return { paired: false, disconnected: true }
|
||||
}
|
||||
|
||||
async function hasCurrentPlatformTab(platformId) {
|
||||
const patterns = PLATFORM_TAB_PATTERNS[platformId] || []
|
||||
if (patterns.length === 0) return false
|
||||
const tabs = await chrome.tabs.query({ url: patterns })
|
||||
return tabs.some(tab => tab.id && !tab.discarded)
|
||||
}
|
||||
|
||||
async function inspectCurrentPlatformAccount(platformId) {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GET_PLATFORM_ACCOUNT',
|
||||
platformId,
|
||||
})
|
||||
if (!response?.success || !response.platformUid) {
|
||||
return {
|
||||
account: null,
|
||||
error: response?.error
|
||||
|| (response?.loggedIn
|
||||
? '已检测到登录状态,但平台没有返回稳定账号标识'
|
||||
: '平台登录状态未通过核对'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
account: {
|
||||
platform: platformId,
|
||||
platformUid: String(response.platformUid),
|
||||
displayName: response.displayName || String(response.platformUid),
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentPlatformAccount(platformId) {
|
||||
return (await inspectCurrentPlatformAccount(platformId)).account
|
||||
}
|
||||
|
||||
async function inspectCurrentPlatformAccounts() {
|
||||
const accounts = []
|
||||
const checks = []
|
||||
for (const platformId of BROWSER_AGENT_PLATFORM_IDS) {
|
||||
if (!await hasCurrentPlatformTab(platformId)) continue
|
||||
try {
|
||||
const result = await inspectCurrentPlatformAccount(platformId)
|
||||
if (result.account) {
|
||||
accounts.push(result.account)
|
||||
checks.push({
|
||||
platform: platformId,
|
||||
status: 'verified',
|
||||
message: '账号已核对',
|
||||
})
|
||||
} else {
|
||||
checks.push({
|
||||
platform: platformId,
|
||||
status: 'failed',
|
||||
message: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
platform: platformId,
|
||||
status: 'failed',
|
||||
message: getMessage(error, '平台账号检测失败'),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { accounts, checks }
|
||||
}
|
||||
|
||||
async function listCurrentPlatformAccounts() {
|
||||
return (await inspectCurrentPlatformAccounts()).accounts
|
||||
}
|
||||
|
||||
function toTaskContent(task) {
|
||||
const body = typeof task.body === 'string' ? task.body : ''
|
||||
const isHtml = /<[a-z][\w:-]*(?:\s[^<>]*)?>/i.test(body)
|
||||
return {
|
||||
title: typeof task.title === 'string' ? task.title : '',
|
||||
body,
|
||||
markdown: body,
|
||||
html: isHtml ? body : renderToutiaoMarkdown(body),
|
||||
plainText: isHtml ? body.replace(/<[^>]+>/g, ' ') : renderToutiaoPlainText(body),
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskResult(task, account, status, extra = {}) {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
status,
|
||||
platformUid: account.platformUid,
|
||||
extensionVersion: getExtensionVersion(),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
async function clickPlatformAction(platformId, tabId, publishMode, providerConfig) {
|
||||
const action = getPlatformAction(platformId, publishMode, providerConfig)
|
||||
if (!action) {
|
||||
return {
|
||||
success: false,
|
||||
message: '该平台未定义受控' + (publishMode === 'draft' ? '存草稿' : '发布') + '动作',
|
||||
}
|
||||
}
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (selector, text) => {
|
||||
const normalize = value => String(value || '').replace(/\s+/g, '')
|
||||
const button = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => normalize(candidate.textContent) === normalize(text))
|
||||
if (!button) return { success: false, message: '未找到目标操作按钮' }
|
||||
if (button.disabled || button.getAttribute('aria-disabled') === 'true') {
|
||||
return { success: false, message: '目标操作按钮当前不可用' }
|
||||
}
|
||||
button.click()
|
||||
return { success: true }
|
||||
},
|
||||
args: [action.selector, action.text],
|
||||
world: 'MAIN',
|
||||
})
|
||||
return result?.[0]?.result || { success: false, message: '无法执行平台操作' }
|
||||
}
|
||||
|
||||
async function inspectTaskState(task, tabId) {
|
||||
return await chrome.runtime.sendMessage({
|
||||
type: 'INSPECT_PLATFORM_TASK',
|
||||
platformId: task.platform,
|
||||
tabId,
|
||||
expectedPlatformUid: task.platformUid,
|
||||
})
|
||||
}
|
||||
|
||||
async function executeClaimedTask(task) {
|
||||
if (!BROWSER_AGENT_PLATFORM_IDS.includes(task.platform)) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前扩展版本未开放该平台的浏览器 Agent 执行器',
|
||||
}
|
||||
}
|
||||
const account = await getCurrentPlatformAccount(task.platform)
|
||||
if (!account) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前浏览器无法确认任务绑定的平台账号',
|
||||
}
|
||||
}
|
||||
if (account.platformUid !== task.platformUid) {
|
||||
return {
|
||||
report: false,
|
||||
message: '当前浏览器登录账号与任务绑定账号不一致,任务将等待租约过期后重新领取',
|
||||
}
|
||||
}
|
||||
if (Date.parse(task.handoff?.expiresAt || '') <= Date.now()) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: '浏览器发布任务租约已过期,未执行平台操作',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
let providerConfig
|
||||
try {
|
||||
providerConfig = task.handoff?.providerConfig === undefined
|
||||
? undefined
|
||||
: validateBrowserProviderSnapshot(task.handoff.providerConfig, task.platform)
|
||||
} catch (error) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: getMessage(error, '声明式 Browser Provider 配置无效'),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
let opened
|
||||
try {
|
||||
opened = await chrome.runtime.sendMessage({
|
||||
type: 'OPEN_PLATFORM_TASK',
|
||||
platformId: task.platform,
|
||||
content: toTaskContent(task),
|
||||
expectedPlatformUid: task.platformUid,
|
||||
providerConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: getMessage(error, '打开平台编辑器失败'),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (!opened?.success || !Number.isInteger(opened.tabId) || opened.tabId <= 0) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
message: opened?.error || opened?.message || '平台编辑器内容填充失败',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const executionPolicy = getTaskExecutionPolicy(
|
||||
task.platform,
|
||||
task.handoff.publishMode,
|
||||
opened.status,
|
||||
providerConfig,
|
||||
)
|
||||
if (executionPolicy !== 'perform_action') {
|
||||
const status = {
|
||||
report_published: 'published',
|
||||
report_draft_saved: 'draft_saved',
|
||||
report_uncertain: 'publication_uncertain',
|
||||
report_filled: 'filled',
|
||||
}[executionPolicy]
|
||||
const fallbackMessage = status === 'filled'
|
||||
? task.handoff.publishMode === 'publish'
|
||||
? '内容已自动填充,当前平台发布动作尚未完成受控验证,请在已打开的编辑器中人工确认发布'
|
||||
: '内容已自动填充,当前平台草稿动作尚未完成受控验证,请在已打开的编辑器中人工保存草稿'
|
||||
: undefined
|
||||
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, status, {
|
||||
tabId: opened.tabId,
|
||||
url: opened.url,
|
||||
message: opened.message || fallbackMessage,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const clickResult = await clickPlatformAction(
|
||||
task.platform,
|
||||
opened.tabId,
|
||||
task.handoff.publishMode,
|
||||
providerConfig,
|
||||
)
|
||||
if (!clickResult.success) {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
tabId: opened.tabId,
|
||||
url: opened.url,
|
||||
message: clickResult.message || '平台操作未执行',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1600))
|
||||
const inspected = await inspectTaskState(task, opened.tabId)
|
||||
if (!inspected?.success && inspected?.status !== 'publication_uncertain') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'failed', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: inspected?.error || '无法核对平台操作结果',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (task.handoff.publishMode === 'draft') {
|
||||
if (inspected.status === 'draft_saved') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'draft_saved', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'publication_uncertain', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: inspected?.message || '已点击存草稿,但平台未返回可确认的草稿结果',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (inspected.status === 'published') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'published', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (inspected.status === 'draft_saved') {
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'draft_saved', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected.url,
|
||||
message: inspected.message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
report: true,
|
||||
result: createTaskResult(task, account, 'publication_uncertain', {
|
||||
tabId: opened.tabId,
|
||||
url: inspected?.url || opened.url,
|
||||
message: '已点击第一层发布按钮,但未确认最终公开结果;平台可能需要人工二次确认',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function pollBrowserAgentNow() {
|
||||
if (pollingPromise) return pollingPromise
|
||||
|
||||
pollingPromise = (async () => {
|
||||
const agent = await getStoredBrowserAgent()
|
||||
if (!agent) return { success: false, status: 'not_paired' }
|
||||
|
||||
const { accounts, checks: accountChecks } = await inspectCurrentPlatformAccounts()
|
||||
if (accounts.length === 0) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'waiting_for_platform_account',
|
||||
message: accountChecks.length > 0
|
||||
? `已打开 ${accountChecks.length} 个受支持平台,但账号核对未通过`
|
||||
: '未找到已打开的受支持平台标签页',
|
||||
lastError: undefined,
|
||||
accounts: [],
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: true, accounts: [], accountChecks })
|
||||
}
|
||||
|
||||
let claimed
|
||||
try {
|
||||
claimed = await requestBrowserAgent(agent, '/v2/channels/browser-agents/claim', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
extensionVersion: getExtensionVersion(),
|
||||
accounts: accounts.map(account => ({
|
||||
platform: account.platform,
|
||||
platformUid: account.platformUid,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'offline',
|
||||
lastError: getMessage(error, '领取浏览器任务失败'),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks })
|
||||
}
|
||||
|
||||
const task = claimed?.task
|
||||
if (!task) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'idle',
|
||||
message: '没有等待执行的浏览器发布任务',
|
||||
lastError: undefined,
|
||||
lastClaimAt: new Date().toISOString(),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: true, accounts, accountChecks, task: null })
|
||||
}
|
||||
|
||||
const execution = await executeClaimedTask(task)
|
||||
if (!execution.report) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'account_mismatch',
|
||||
message: execution.message,
|
||||
lastTaskId: task.taskId,
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
|
||||
}
|
||||
|
||||
try {
|
||||
await requestBrowserAgent(agent, '/v2/channels/browser-agents/' + encodeURIComponent(task.taskId) + '/result', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(execution.result),
|
||||
})
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: execution.result.status,
|
||||
message: execution.result.message,
|
||||
lastError: undefined,
|
||||
lastTaskId: task.taskId,
|
||||
lastResultAt: new Date().toISOString(),
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, {
|
||||
success: true,
|
||||
accounts,
|
||||
accountChecks,
|
||||
taskId: task.taskId,
|
||||
result: execution.result,
|
||||
})
|
||||
} catch (error) {
|
||||
const next = await updateRuntimeState(agent, {
|
||||
status: 'result_sync_failed',
|
||||
message: '平台操作已完成,但结果回写失败',
|
||||
lastError: getMessage(error, '浏览器任务结果回写失败'),
|
||||
lastTaskId: task.taskId,
|
||||
accounts,
|
||||
accountChecks,
|
||||
})
|
||||
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
|
||||
}
|
||||
})().finally(() => {
|
||||
pollingPromise = null
|
||||
})
|
||||
|
||||
return pollingPromise
|
||||
}
|
||||
|
||||
function installBrowserAgentRuntime() {
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
void syncPollingAlarm()
|
||||
})
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
void syncPollingAlarm()
|
||||
})
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === BROWSER_AGENT_ALARM_NAME) {
|
||||
void pollBrowserAgentNow()
|
||||
}
|
||||
})
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (![
|
||||
'PAIR_BROWSER_AGENT',
|
||||
'GET_BROWSER_AGENT_STATUS',
|
||||
'POLL_BROWSER_AGENT_NOW',
|
||||
'DISCONNECT_BROWSER_AGENT',
|
||||
].includes(request?.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
switch (request.type) {
|
||||
case 'PAIR_BROWSER_AGENT':
|
||||
sendResponse(await pairBrowserAgent(request.payload))
|
||||
return
|
||||
case 'GET_BROWSER_AGENT_STATUS':
|
||||
sendResponse(await getBrowserAgentStatus())
|
||||
return
|
||||
case 'POLL_BROWSER_AGENT_NOW':
|
||||
sendResponse(await pollBrowserAgentNow())
|
||||
return
|
||||
case 'DISCONNECT_BROWSER_AGENT':
|
||||
sendResponse(await disconnectBrowserAgent())
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
sendResponse({ error: getMessage(error, '浏览器 Agent 操作失败') })
|
||||
}
|
||||
})()
|
||||
return true
|
||||
})
|
||||
void syncPollingAlarm()
|
||||
}
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage && chrome.alarms) {
|
||||
installBrowserAgentRuntime()
|
||||
}
|
||||
|
||||
export {
|
||||
ALLOWED_API_ORIGINS,
|
||||
BROWSER_AGENT_PLATFORM_IDS,
|
||||
PLATFORM_TAB_PATTERNS,
|
||||
executeClaimedTask,
|
||||
getPlatformAction,
|
||||
getTaskExecutionPolicy,
|
||||
inspectCurrentPlatformAccounts,
|
||||
listCurrentPlatformAccounts,
|
||||
normalizeApiBaseUrl,
|
||||
toTaskContent,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,10 @@
|
||||
# COSE upstream
|
||||
|
||||
The files under this directory are adapted from
|
||||
[`doocs/cose`](https://github.com/doocs/cose) at commit
|
||||
`e70fa9e92a71cd2f10e0c883981f324a332162d4`.
|
||||
|
||||
The upstream Apache License 2.0 is preserved in `LICENSE`. AiToEarn owns the
|
||||
browser bridge, product UI, payload contract, packaging, and release process;
|
||||
the platform fillers and login detectors remain isolated here so future
|
||||
upstream updates can be reviewed and applied without replacing the extension.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
const CONTROLLED_BROWSER_PROVIDER_PLATFORM_IDS = new Set([
|
||||
'csdn',
|
||||
'juejin',
|
||||
'jianshu',
|
||||
'zhihu',
|
||||
'toutiao',
|
||||
'baijiahao',
|
||||
'wangyi',
|
||||
'sohu',
|
||||
'infoq',
|
||||
'tencentcloud',
|
||||
'aliyun',
|
||||
'segmentfault',
|
||||
'51cto',
|
||||
'oschina',
|
||||
'qiehao',
|
||||
])
|
||||
|
||||
const EDITOR_TYPES = new Set(['textarea', 'contenteditable', 'codemirror5'])
|
||||
const CONTENT_FORMATS = new Set(['markdown', 'html', 'plainText'])
|
||||
|
||||
function isRecord(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertExactKeys(value, allowedKeys, label) {
|
||||
const unexpected = Object.keys(value).filter(key => !allowedKeys.includes(key))
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(`${label}包含不允许的字段:${unexpected.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertString(value, label, maximumLength, pattern) {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > maximumLength) {
|
||||
throw new Error(`${label}无效`)
|
||||
}
|
||||
if (pattern && !pattern.test(value)) throw new Error(`${label}格式无效`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function validateSelector(value, label) {
|
||||
return assertString(value, label, 500, /^[^\u0000-\u001f\u007f]+$/)
|
||||
}
|
||||
|
||||
function validateAction(value, label) {
|
||||
if (value === undefined) return undefined
|
||||
if (!isRecord(value)) throw new Error(`${label}无效`)
|
||||
assertExactKeys(value, ['selector', 'exactText'], label)
|
||||
return {
|
||||
selector: validateSelector(value.selector, `${label}选择器`),
|
||||
exactText: assertString(value.exactText, `${label}按钮文字`, 100),
|
||||
}
|
||||
}
|
||||
|
||||
function validateBrowserProviderSnapshot(value, expectedPlatformId) {
|
||||
if (value === undefined) return null
|
||||
if (!isRecord(value)) throw new Error('声明式 Browser Provider 快照无效')
|
||||
assertExactKeys(
|
||||
value,
|
||||
['pluginId', 'version', 'sourceCommit', 'contentHash', 'definition'],
|
||||
'声明式 Browser Provider 快照',
|
||||
)
|
||||
|
||||
const pluginId = assertString(value.pluginId, 'Provider 插件 ID', 100, /^[a-z\d][\w.-]*$/i)
|
||||
const version = assertString(value.version, 'Provider 插件版本', 64)
|
||||
const sourceCommit = assertString(value.sourceCommit, 'Provider 源提交', 40, /^[0-9a-f]{40}$/i)
|
||||
const contentHash = assertString(value.contentHash, 'Provider 内容哈希', 64, /^[0-9a-f]{64}$/i)
|
||||
const definition = value.definition
|
||||
if (!isRecord(definition)) throw new Error('声明式 Browser Provider 定义无效')
|
||||
assertExactKeys(
|
||||
definition,
|
||||
['platform', 'title', 'content', 'draftAction', 'publishAction'],
|
||||
'声明式 Browser Provider 定义',
|
||||
)
|
||||
|
||||
const platform = assertString(definition.platform, 'Provider 平台', 32)
|
||||
if (!CONTROLLED_BROWSER_PROVIDER_PLATFORM_IDS.has(platform) || platform !== expectedPlatformId) {
|
||||
throw new Error('声明式 Browser Provider 平台与任务平台不一致')
|
||||
}
|
||||
|
||||
if (!isRecord(definition.title)) throw new Error('Provider 标题字段无效')
|
||||
assertExactKeys(definition.title, ['selector', 'activationSelector'], 'Provider 标题字段')
|
||||
const title = {
|
||||
selector: validateSelector(definition.title.selector, 'Provider 标题选择器'),
|
||||
}
|
||||
if (definition.title.activationSelector !== undefined) {
|
||||
title.activationSelector = validateSelector(
|
||||
definition.title.activationSelector,
|
||||
'Provider 标题激活选择器',
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRecord(definition.content)) throw new Error('Provider 正文字段无效')
|
||||
assertExactKeys(definition.content, ['selector', 'editorType', 'format'], 'Provider 正文字段')
|
||||
const editorType = assertString(definition.content.editorType, 'Provider 编辑器类型', 32)
|
||||
const format = assertString(definition.content.format, 'Provider 内容格式', 32)
|
||||
if (!EDITOR_TYPES.has(editorType)) throw new Error('Provider 编辑器类型不受支持')
|
||||
if (!CONTENT_FORMATS.has(format)) throw new Error('Provider 内容格式不受支持')
|
||||
|
||||
const normalizedDefinition = {
|
||||
platform,
|
||||
title,
|
||||
content: {
|
||||
selector: validateSelector(definition.content.selector, 'Provider 正文选择器'),
|
||||
editorType,
|
||||
format,
|
||||
},
|
||||
}
|
||||
const draftAction = validateAction(definition.draftAction, 'Provider 草稿动作')
|
||||
const publishAction = validateAction(definition.publishAction, 'Provider 发布动作')
|
||||
if (draftAction) normalizedDefinition.draftAction = draftAction
|
||||
if (publishAction) normalizedDefinition.publishAction = publishAction
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
version,
|
||||
sourceCommit,
|
||||
contentHash,
|
||||
definition: normalizedDefinition,
|
||||
}
|
||||
}
|
||||
|
||||
function getDeclarativeProviderAction(providerConfig, platformId, publishMode) {
|
||||
const validated = validateBrowserProviderSnapshot(providerConfig, platformId)
|
||||
if (!validated) return null
|
||||
const action = publishMode === 'draft'
|
||||
? validated.definition.draftAction
|
||||
: publishMode === 'publish'
|
||||
? validated.definition.publishAction
|
||||
: undefined
|
||||
if (!action) return null
|
||||
return { selector: action.selector, text: action.exactText }
|
||||
}
|
||||
|
||||
async function fillDeclarativeBrowserProvider(definition, content) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
|
||||
function findElement(selector) {
|
||||
const candidates = Array.from(document.querySelectorAll(selector))
|
||||
return candidates.find((element) => {
|
||||
const style = window.getComputedStyle?.(element)
|
||||
return !style || (style.display !== 'none' && style.visibility !== 'hidden')
|
||||
}) || null
|
||||
}
|
||||
|
||||
async function waitForElement(selector, timeout = 15000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
const element = findElement(selector)
|
||||
if (element) return element
|
||||
await sleep(100)
|
||||
}
|
||||
return findElement(selector)
|
||||
}
|
||||
|
||||
function dispatchValueEvents(element, value, inputType) {
|
||||
try {
|
||||
element.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType,
|
||||
}))
|
||||
} catch {
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setNativeValue(element, value) {
|
||||
element.focus()
|
||||
const prototype = element instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(element, value)
|
||||
else element.value = value
|
||||
dispatchValueEvents(element, value, 'insertText')
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function setContentEditableValue(element, value, format, plainText) {
|
||||
element.focus()
|
||||
selectContents(element)
|
||||
const command = format === 'html' ? 'insertHTML' : 'insertText'
|
||||
const inserted = document.execCommand?.(command, false, value)
|
||||
if (!inserted) {
|
||||
if (format === 'html') element.innerHTML = value
|
||||
else element.textContent = value
|
||||
}
|
||||
dispatchValueEvents(element, plainText || value, format === 'html' ? 'insertFromPaste' : 'insertText')
|
||||
}
|
||||
|
||||
function getCodeMirror(element) {
|
||||
return element?.CodeMirror || element?.closest?.('.CodeMirror')?.CodeMirror
|
||||
}
|
||||
|
||||
const title = typeof content.title === 'string' ? content.title : ''
|
||||
if (definition.title.activationSelector) {
|
||||
const activator = await waitForElement(definition.title.activationSelector)
|
||||
if (!activator) return { success: false, error: '未找到 Provider 标题激活控件' }
|
||||
activator.click()
|
||||
await sleep(150)
|
||||
}
|
||||
|
||||
const titleElement = await waitForElement(definition.title.selector)
|
||||
if (!titleElement) return { success: false, error: '未找到 Provider 标题输入框' }
|
||||
if (title) {
|
||||
if (titleElement instanceof HTMLInputElement || titleElement instanceof HTMLTextAreaElement) {
|
||||
setNativeValue(titleElement, title)
|
||||
} else if (titleElement.isContentEditable || titleElement.getAttribute('contenteditable') === 'true') {
|
||||
setContentEditableValue(titleElement, title, 'plainText', title)
|
||||
} else {
|
||||
return { success: false, error: 'Provider 标题选择器没有指向可写控件' }
|
||||
}
|
||||
await sleep(200)
|
||||
const actualTitle = 'value' in titleElement ? titleElement.value : titleElement.textContent
|
||||
if (normalizeText(actualTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: '平台未确认接收 Provider 标题' }
|
||||
}
|
||||
}
|
||||
|
||||
const contentValue = typeof content[definition.content.format] === 'string'
|
||||
? content[definition.content.format]
|
||||
: typeof content.body === 'string'
|
||||
? content.body
|
||||
: ''
|
||||
if (!contentValue) return { success: true, method: 'title-only', textLength: 0, imageCount: 0 }
|
||||
|
||||
const contentElement = await waitForElement(definition.content.selector)
|
||||
if (!contentElement) return { success: false, error: '未找到 Provider 正文编辑器' }
|
||||
|
||||
let actualContent = ''
|
||||
if (definition.content.editorType === 'codemirror5') {
|
||||
const editor = getCodeMirror(contentElement)
|
||||
if (!editor?.setValue || !editor?.getValue) {
|
||||
return { success: false, error: 'Provider 正文选择器没有指向 CodeMirror 5 编辑器' }
|
||||
}
|
||||
editor.setValue(contentValue)
|
||||
editor.focus?.()
|
||||
actualContent = editor.getValue()
|
||||
} else if (definition.content.editorType === 'textarea') {
|
||||
if (!(contentElement instanceof HTMLTextAreaElement || contentElement instanceof HTMLInputElement)) {
|
||||
return { success: false, error: 'Provider 正文选择器没有指向文本输入控件' }
|
||||
}
|
||||
setNativeValue(contentElement, contentValue)
|
||||
actualContent = contentElement.value
|
||||
} else {
|
||||
if (!contentElement.isContentEditable && contentElement.getAttribute('contenteditable') !== 'true') {
|
||||
return { success: false, error: 'Provider 正文选择器没有指向富文本编辑器' }
|
||||
}
|
||||
setContentEditableValue(
|
||||
contentElement,
|
||||
contentValue,
|
||||
definition.content.format,
|
||||
content.plainText,
|
||||
)
|
||||
actualContent = contentElement.innerText || contentElement.textContent || ''
|
||||
}
|
||||
|
||||
await sleep(500)
|
||||
if (definition.content.editorType === 'codemirror5') {
|
||||
actualContent = getCodeMirror(contentElement)?.getValue?.() || ''
|
||||
} else if (definition.content.editorType === 'textarea') {
|
||||
actualContent = contentElement.value || ''
|
||||
} else {
|
||||
actualContent = contentElement.innerText || contentElement.textContent || ''
|
||||
}
|
||||
|
||||
const expectedText = definition.content.editorType === 'contenteditable'
|
||||
? content.plainText || contentValue.replace(/<[^>]+>/g, ' ')
|
||||
: contentValue
|
||||
const normalizedExpected = normalizeText(expectedText)
|
||||
const normalizedActual = normalizeText(actualContent)
|
||||
const minimumLength = Math.min(
|
||||
normalizedExpected.length,
|
||||
Math.min(Math.max(12, Math.floor(normalizedExpected.length * 0.1)), 120),
|
||||
)
|
||||
const expectedPrefix = normalizedExpected.slice(0, Math.min(32, normalizedExpected.length))
|
||||
const expectedImageCount = definition.content.format === 'html'
|
||||
? (contentValue.match(/<img\b/gi) || []).length
|
||||
: 0
|
||||
const actualImageCount = contentElement.querySelectorAll?.('img').length || 0
|
||||
if (
|
||||
normalizedActual.length < minimumLength
|
||||
|| (expectedPrefix.length >= 8 && !normalizedActual.includes(expectedPrefix))
|
||||
|| actualImageCount < expectedImageCount
|
||||
) {
|
||||
return { success: false, error: '平台未确认接收 Provider 正文,请保留编辑器并人工核对' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: definition.content.editorType,
|
||||
textLength: normalizedActual.length,
|
||||
imageCount: actualImageCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDeclarativeBrowserProvider(platform, content, providerConfig, options, helpers) {
|
||||
const validated = validateBrowserProviderSnapshot(providerConfig, platform.id)
|
||||
let tab
|
||||
if (Number.isInteger(options.tabId) && options.tabId > 0) {
|
||||
try {
|
||||
tab = await helpers.chrome.tabs.update(options.tabId, {
|
||||
url: platform.publishUrl,
|
||||
active: options.active === true,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
if (!tab) {
|
||||
tab = await helpers.chrome.tabs.create({
|
||||
url: platform.publishUrl,
|
||||
active: options.active === true,
|
||||
})
|
||||
await helpers.addTabToSyncGroup(tab.id, tab.windowId)
|
||||
}
|
||||
await helpers.waitForTab(tab.id)
|
||||
|
||||
const execution = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillDeclarativeBrowserProvider,
|
||||
args: [validated.definition, content],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const result = execution?.[0]?.result
|
||||
if (!result?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: result?.error || '声明式 Browser Provider 填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已使用 ${validated.pluginId}@${validated.version} 填充平台编辑器`,
|
||||
tabId: tab.id,
|
||||
provider: {
|
||||
pluginId: validated.pluginId,
|
||||
version: validated.version,
|
||||
contentHash: validated.contentHash,
|
||||
},
|
||||
metrics: {
|
||||
textLength: result.textLength || 0,
|
||||
imageCount: result.imageCount || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
CONTROLLED_BROWSER_PROVIDER_PLATFORM_IDS,
|
||||
fillDeclarativeBrowserProvider,
|
||||
getDeclarativeProviderAction,
|
||||
syncDeclarativeBrowserProvider,
|
||||
validateBrowserProviderSnapshot,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 支付宝开放平台配置
|
||||
const AlipayOpenPlatform = {
|
||||
id: 'alipayopen',
|
||||
name: 'AlipayOpen',
|
||||
icon: 'https://www.alipay.com/favicon.ico',
|
||||
url: 'https://open.alipay.com',
|
||||
publishUrl: 'https://open.alipay.com/portal/forum/post/add#article',
|
||||
title: '支付宝开放平台',
|
||||
type: 'alipayopen',
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付宝开放平台内容填充函数
|
||||
* 注意:此函数会被序列化后通过 chrome.scripting.executeScript 注入页面执行
|
||||
* 因此必须是自包含的,不能依赖外部模块或闭包
|
||||
* @param {string} title - 文章标题
|
||||
* @param {string} markdown - Markdown 内容
|
||||
*/
|
||||
function fillAlipayOpenContent(title, markdown) {
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
return (async () => {
|
||||
try {
|
||||
console.log('[COSE] 支付宝开放平台 开始填充, 标题:', title)
|
||||
|
||||
// 等待页面加载
|
||||
await sleep(500)
|
||||
|
||||
// 填充标题 - 尝试多种选择器
|
||||
let titleInput = document.querySelector('input[placeholder*="标题"]')
|
||||
if (!titleInput) {
|
||||
titleInput = document.querySelector('input[placeholder*="请输入"]')
|
||||
}
|
||||
if (!titleInput) {
|
||||
const allInputs = document.querySelectorAll('input')
|
||||
for (const inp of allInputs) {
|
||||
if (inp.placeholder && inp.placeholder.includes('标题')) {
|
||||
titleInput = inp
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[COSE] 支付宝开放平台 查找标题输入框:', !!titleInput)
|
||||
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
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 }))
|
||||
titleInput.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }))
|
||||
console.log('[COSE] 支付宝开放平台 标题填充成功:', title)
|
||||
} else {
|
||||
console.log('[COSE] 支付宝开放平台 标题填充失败 - input:', !!titleInput, 'title:', !!title)
|
||||
}
|
||||
|
||||
await sleep(300)
|
||||
|
||||
// 填充内容 - 支付宝开放平台使用 ne-engine 富文本编辑器
|
||||
const editor = document.querySelector('.ne-engine')
|
||||
if (editor && markdown) {
|
||||
editor.focus()
|
||||
await sleep(100)
|
||||
|
||||
// 使用 ClipboardEvent 模拟粘贴 Markdown 内容
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/plain', markdown)
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
editor.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] 支付宝开放平台 内容粘贴成功')
|
||||
|
||||
// 等待并点击"立即转换"按钮
|
||||
let confirmed = false
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(200)
|
||||
const convertBtn = Array.from(document.querySelectorAll('button')).find(btn =>
|
||||
btn.textContent.includes('立即转换')
|
||||
)
|
||||
if (convertBtn) {
|
||||
convertBtn.click()
|
||||
confirmed = true
|
||||
console.log('[COSE] 支付宝开放平台 Markdown 转换成功')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, confirmed }
|
||||
}
|
||||
|
||||
return { success: false, error: '未找到编辑器' }
|
||||
} catch (e) {
|
||||
console.error('[COSE] 支付宝开放平台 填充失败:', e)
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { AlipayOpenPlatform, fillAlipayOpenContent }
|
||||
@@ -0,0 +1,375 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const AliyunPlatform = {
|
||||
id: 'aliyun',
|
||||
name: 'Aliyun',
|
||||
icon: 'https://img.alicdn.com/tfs/TB1_ZXuNcfpK1RjSZFOXXa6nFXa-32-32.ico',
|
||||
url: 'https://developer.aliyun.com/',
|
||||
publishUrl: 'https://developer.aliyun.com/article/new',
|
||||
title: '阿里云开发者社区',
|
||||
type: 'aliyun',
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function parseAliyunPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://developer.aliyun.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'developer.aliyun.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/article\/(\d+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认阿里云开发者社区文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAliyunDraftId(url) {
|
||||
return normalizeString(
|
||||
url.searchParams.get('articleId')
|
||||
|| url.searchParams.get('article_id')
|
||||
|| url.searchParams.get('draftId')
|
||||
|| url.searchParams.get('draft_id')
|
||||
|| url.searchParams.get('id'),
|
||||
)
|
||||
}
|
||||
|
||||
function inspectAliyunTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseAliyunPublishedUrl(publicUrl) || parseAliyunPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取阿里云开发者社区标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'developer.aliyun.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开阿里云开发者社区,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/article\/(?:new|edit)\/?$/.test(url.pathname)) {
|
||||
const draftId = getAliyunDraftId(url)
|
||||
if (draftId || draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: draftId
|
||||
? '已确认阿里云开发者社区草稿地址'
|
||||
: '已确认阿里云开发者社区编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到阿里云开发者社区编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前阿里云开发者社区页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillAliyunContent(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
selectContents(editor)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function switchToMarkdownEditor() {
|
||||
const switchButton = Array.from(document.querySelectorAll('button, [role="button"], a'))
|
||||
.find(element => {
|
||||
const text = element.textContent?.replace(/\s+/g, '') || ''
|
||||
return /切换.*(?:Markdown|MD)|(?:Markdown|MD).*编辑器/.test(text)
|
||||
})
|
||||
if (!switchButton) return
|
||||
|
||||
switchButton.click()
|
||||
await sleep(800)
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'#title',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
'.title-input input',
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到阿里云开发者社区标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) {
|
||||
return { success: false, error: '阿里云开发者社区未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const editor = codeMirror?.CodeMirror
|
||||
if (editor && typeof editor.setValue === 'function') {
|
||||
editor.focus()
|
||||
editor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(editor.getValue?.())) return { success: true, method: 'codemirror' }
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.editor textarea.textarea',
|
||||
'textarea#article-editor',
|
||||
'textarea.textarea',
|
||||
'.markdown-editor textarea',
|
||||
'.editor-container textarea',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (textarea) {
|
||||
setInputValue(textarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '阿里云开发者社区未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectAliyunTask(tab, helpers) {
|
||||
const currentState = inspectAliyunTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'developer.aliyun.com'
|
||||
&& /^\/article\/\d+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const visibleText = document.body?.innerText || ''
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|文章已保存|保存成功/.test(visibleText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectAliyunTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAliyunContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取阿里云开发者社区编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillAliyunContent,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '阿里云开发者社区内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充阿里云开发者社区编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
AliyunPlatform,
|
||||
fillAliyunContent,
|
||||
inspectAliyunTask,
|
||||
inspectAliyunTaskState,
|
||||
parseAliyunPublishedUrl,
|
||||
syncAliyunContent,
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const BaijiahaoPlatform = {
|
||||
id: 'baijiahao',
|
||||
name: 'Baijiahao',
|
||||
icon: 'https://pic.rmb.bdstatic.com/10e1e2b43c35577e1315f0f6aad6ba24.vnd.microsoft.icon',
|
||||
url: 'https://baijiahao.baidu.com',
|
||||
publishUrl: 'https://baijiahao.baidu.com/builder/rc/edit?type=news',
|
||||
title: '百家号',
|
||||
type: 'baijiahao',
|
||||
}
|
||||
|
||||
function parseBaijiahaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
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
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: platformWorkId.trim(),
|
||||
message: '已确认百家号文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getBaijiahaoDraftId(url) {
|
||||
for (const key of ['article_id', 'articleId', 'draft_id', 'draftId']) {
|
||||
const value = url.searchParams.get(key)
|
||||
if (value?.trim()) return value.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inspectBaijiahaoTaskState(rawUrl, publicUrl) {
|
||||
const published = parseBaijiahaoPublishedUrl(publicUrl) || parseBaijiahaoPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取百家号标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'baijiahao.baidu.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开百家号,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/builder\/rc\/edit\/?$/.test(url.pathname)) {
|
||||
const draftId = getBaijiahaoDraftId(url)
|
||||
if (draftId) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: '已确认百家号草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到百家号编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/builder\/(?:rc\/)?(?:content|article|manage)/.test(url.pathname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '已离开百家号本次编辑器,请打开本次文章核对发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前百家号页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillBaijiahaoContent(title, html, plainText) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function waitForCondition(getValue, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = getValue()
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const value = getValue()
|
||||
if (!value) return
|
||||
observer.disconnect()
|
||||
resolve(value)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(getValue())
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const ownerDocument = element.ownerDocument
|
||||
const selection = ownerDocument.defaultView?.getSelection()
|
||||
const range = ownerDocument.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function setEditableText(element, value) {
|
||||
const ownerDocument = element.ownerDocument
|
||||
element.focus()
|
||||
selectContents(element)
|
||||
ownerDocument.execCommand?.('insertText', false, value)
|
||||
if (normalizeText(element.textContent) !== normalizeText(value)) element.textContent = value
|
||||
element.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setEditableHtml(element, nextHtml, nextPlainText) {
|
||||
const ownerDocument = element.ownerDocument
|
||||
element.focus()
|
||||
selectContents(element)
|
||||
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted || normalizeText(element.textContent).length + element.querySelectorAll('img').length * 12 < 1) {
|
||||
element.innerHTML = nextHtml
|
||||
}
|
||||
element.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
function findUeditor() {
|
||||
const instances = window.UE_V2?.instants
|
||||
if (!instances || typeof instances !== 'object') return undefined
|
||||
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
|
||||
}
|
||||
|
||||
function findEditorIframe() {
|
||||
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
|
||||
const body = iframe.contentDocument?.body
|
||||
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
function hasExpectedContent(text, imageCount) {
|
||||
const editorText = normalizeComparableText(text)
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleEditor = await waitForCondition(() => (
|
||||
document.querySelector('.client_components_titleInput [contenteditable="true"]')
|
||||
|| document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]')
|
||||
|| document.querySelector('[class*="titleInput"] [contenteditable="true"]')
|
||||
|| document.querySelector('[contenteditable="true"][placeholder*="标题"]')
|
||||
))
|
||||
if (!titleEditor) return { success: false, error: '未找到百家号标题输入框' }
|
||||
if (title) {
|
||||
setEditableText(titleEditor, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleEditor.textContent) !== normalizeText(title)) {
|
||||
return { success: false, error: '百家号未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = await waitForCondition(findUeditor, 7000)
|
||||
if (editor) {
|
||||
try {
|
||||
editor.setContent(html)
|
||||
editor.fireEvent?.('contentchange')
|
||||
editor.fireEvent?.('selectionchange')
|
||||
await sleep(500)
|
||||
const serialized = editor.getContent?.() || ''
|
||||
const text = editor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
|
||||
const imageCount = (serialized.match(/<img\b/gi) || []).length
|
||||
if (hasExpectedContent(text, imageCount)) {
|
||||
return { success: true, method: 'ueditor', imageCount }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const iframe = await waitForCondition(findEditorIframe, 7000)
|
||||
const iframeBody = iframe?.contentDocument?.body
|
||||
if (iframeBody?.isContentEditable) {
|
||||
setEditableHtml(iframeBody, html, plainText)
|
||||
await sleep(500)
|
||||
const imageCount = iframeBody.querySelectorAll('img').length
|
||||
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
|
||||
return { success: true, method: 'iframe', imageCount }
|
||||
}
|
||||
}
|
||||
|
||||
const contentEditor = Array.from(document.querySelectorAll('[contenteditable="true"]')).find(element => {
|
||||
return element !== titleEditor && !titleEditor.contains(element) && !element.contains(titleEditor)
|
||||
})
|
||||
if (contentEditor) {
|
||||
setEditableHtml(contentEditor, html, plainText)
|
||||
await sleep(500)
|
||||
const imageCount = contentEditor.querySelectorAll('img').length
|
||||
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
|
||||
return { success: true, method: 'contenteditable', imageCount }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '百家号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectBaijiahaoTask(tab, helpers) {
|
||||
const currentState = inspectBaijiahaoTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
return url.hostname === 'baijiahao.baidu.com'
|
||||
&& url.pathname === '/s'
|
||||
&& Boolean(url.searchParams.get('id'))
|
||||
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(link.textContent || '')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(document.body?.innerText || ''),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
const state = inspectBaijiahaoTaskState(tab.url || '', observed?.publicUrl)
|
||||
if (state.status === 'filled' && observed?.draftSaved) {
|
||||
return {
|
||||
...state,
|
||||
status: 'draft_saved',
|
||||
message: '已确认百家号编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBaijiahaoContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取百家号编辑器标签页' }
|
||||
|
||||
const markdown = content.markdown || content.body || ''
|
||||
await helpers.waitForTab(tab.id)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillBaijiahaoContent,
|
||||
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '百家号内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已打开并填充百家号编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BaijiahaoPlatform,
|
||||
fillBaijiahaoContent,
|
||||
inspectBaijiahaoTask,
|
||||
inspectBaijiahaoTaskState,
|
||||
parseBaijiahaoPublishedUrl,
|
||||
syncBaijiahaoContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// B站专栏平台配置(使用旧版编辑器,基于 UEditor)
|
||||
// 同步方式:使用 UEditor execCommand('inserthtml') 插入 HTML
|
||||
const BilibiliPlatform = {
|
||||
id: 'bilibili',
|
||||
name: 'Bilibili',
|
||||
icon: 'https://www.bilibili.com/favicon.ico',
|
||||
url: 'https://member.bilibili.com',
|
||||
publishUrl: 'https://member.bilibili.com/article-text/home?newEditor=-1',
|
||||
title: 'B站专栏',
|
||||
type: 'bilibili',
|
||||
}
|
||||
|
||||
// B站专栏内容填充函数(由 background.js 处理)
|
||||
// 使用 UEditor 的 execCommand('inserthtml') 方法插入 HTML 内容
|
||||
async function fillBilibiliContent(content, waitFor, setInputValue) {
|
||||
console.log('[COSE] B站专栏填充由 background.js 处理')
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { BilibiliPlatform, fillBilibiliContent }
|
||||
@@ -0,0 +1,59 @@
|
||||
// 博客园平台配置
|
||||
const CnblogsPlatform = {
|
||||
id: 'cnblogs',
|
||||
name: 'Cnblogs',
|
||||
icon: 'https://www.cnblogs.com/favicon.ico',
|
||||
url: 'https://www.cnblogs.com',
|
||||
publishUrl: 'https://i.cnblogs.com/posts/edit',
|
||||
title: '博客园',
|
||||
type: 'cnblogs',
|
||||
}
|
||||
|
||||
// 博客园内容填充函数
|
||||
async function fillCnblogsContent(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
// 填充标题
|
||||
const titleInput = await waitFor('#post-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] 博客园标题填充成功')
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 博客园使用 TinyMCE 或 Markdown 编辑器
|
||||
// 尝试 Markdown 模式
|
||||
const cmElement = document.querySelector('.CodeMirror')
|
||||
if (cmElement && cmElement.CodeMirror) {
|
||||
cmElement.CodeMirror.setValue(contentToFill)
|
||||
console.log('[COSE] 博客园 CodeMirror 填充成功')
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试 TinyMCE
|
||||
if (window.tinymce && window.tinymce.activeEditor) {
|
||||
window.tinymce.activeEditor.setContent(contentToFill)
|
||||
console.log('[COSE] 博客园 TinyMCE 填充成功')
|
||||
return
|
||||
}
|
||||
|
||||
// 降级到 textarea
|
||||
const textarea = document.querySelector('#post-body, textarea')
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.value = contentToFill
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
console.log('[COSE] 博客园 textarea 填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 博客园 未找到编辑器')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { CnblogsPlatform, fillCnblogsContent }
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from utils.js for backward compatibility
|
||||
export * from '../utils.js'
|
||||
export { injectUtils } from '../utils.js'
|
||||
@@ -0,0 +1,291 @@
|
||||
const CSDNPlatform = {
|
||||
id: 'csdn',
|
||||
name: 'CSDN',
|
||||
icon: 'https://g.csdnimg.cn/static/logo/favicon32.ico',
|
||||
url: 'https://blog.csdn.net',
|
||||
publishUrl: 'https://editor.csdn.net/md/',
|
||||
title: 'CSDN',
|
||||
type: 'csdn',
|
||||
}
|
||||
|
||||
function parseCsdnPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.csdn.net') return null
|
||||
|
||||
const published = url.pathname.match(/^\/([^/]+)\/article\/details\/(\d+)\/?$/)
|
||||
if (!published?.[2]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[2],
|
||||
message: '已确认 CSDN 文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectCsdnTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseCsdnPublishedUrl(publicUrl) || parseCsdnPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取 CSDN 编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'editor.csdn.net') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开 CSDN,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
const articleId = url.searchParams.get('articleId') || url.searchParams.get('article_id')
|
||||
if (articleId && draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: articleId,
|
||||
message: '已确认 CSDN 编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: articleId
|
||||
? '内容已填充到 CSDN 编辑器,请等待保存完成或手动保存草稿'
|
||||
: '内容仍在 CSDN 新建编辑器中,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
function fillCsdnContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
for (const element of document.querySelectorAll(selector)) {
|
||||
if (element !== excludedElement && !element.disabled && element.offsetParent !== null) return element
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setContentEditableValue(editor, value) {
|
||||
editor.focus()
|
||||
selectContents(editor)
|
||||
const inserted = document.execCommand?.('insertText', false, value)
|
||||
if (!inserted || normalizeText(editor.textContent) !== normalizeText(value)) editor.textContent = value
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const actualContent = editor?.value ?? editor?.innerText ?? editor?.textContent
|
||||
return normalizeContent(actualContent) === normalizeContent(contentToFill)
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
if (!expectedTitle) return true
|
||||
|
||||
const inputTitle = normalizeText(input?.value)
|
||||
const displayTitle = normalizeText(document.querySelector('.article-bar__title-display')?.textContent)
|
||||
return inputTitle === expectedTitle || displayTitle === expectedTitle
|
||||
}
|
||||
|
||||
async function getTitleInput() {
|
||||
const selectors = [
|
||||
'input.article-bar__title--input',
|
||||
'.article-bar__input-box input[placeholder*="标题"]',
|
||||
'input[placeholder*="文章标题"]',
|
||||
'input[name="title"]',
|
||||
'input.title-input',
|
||||
]
|
||||
const visibleInput = findFirst(selectors)
|
||||
if (visibleInput) return visibleInput
|
||||
|
||||
const titleDisplay = findFirst(['.article-bar__title-display'])
|
||||
titleDisplay?.click()
|
||||
return waitForElement(selectors, undefined, 2500)
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = title ? await getTitleInput() : null
|
||||
if (title && !titleInput) return { success: false, error: '未找到 CSDN 标题输入框' }
|
||||
if (titleInput && title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) return { success: false, error: 'CSDN 未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
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 (normalizeContent(editor.CodeMirror.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
} else if (editor instanceof HTMLTextAreaElement) {
|
||||
setInputValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (hasExpectedContent(editor)) return { success: true, method: 'textarea' }
|
||||
} else if (editor.isContentEditable) {
|
||||
setContentEditableValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (hasExpectedContent(editor)) return { success: true, method: 'contenteditable' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'CSDN 未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectCsdnTask(tab, helpers) {
|
||||
const currentState = inspectCsdnTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'blog.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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const CTO51Platform = {
|
||||
id: '51cto',
|
||||
name: '51CTO',
|
||||
icon: 'https://blog.51cto.com/favicon.ico',
|
||||
url: 'https://blog.51cto.com',
|
||||
loginUrl: 'https://home.51cto.com/index/login',
|
||||
publishUrl: 'https://blog.51cto.com/blogger/publish',
|
||||
title: '51CTO',
|
||||
type: '51cto',
|
||||
}
|
||||
|
||||
function parseCto51PublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://blog.51cto.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.51cto.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/u_(\d+)\/(\d+)\/?$/)
|
||||
if (!published?.[2]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[2],
|
||||
message: '已确认 51CTO 文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectCto51TaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseCto51PublishedUrl(publicUrl) || parseCto51PublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取 51CTO 编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.51cto.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开 51CTO,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/blogger\/publish\/?$/.test(url.pathname)) {
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认 51CTO 编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到 51CTO 编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前 51CTO 页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillCto51Content(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'#title',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到 51CTO 标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(100)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '51CTO 未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const codeMirrorEditor = codeMirror?.CodeMirror
|
||||
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
|
||||
codeMirrorEditor.focus()
|
||||
codeMirrorEditor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
}
|
||||
|
||||
const markdownTextarea = await waitForElement([
|
||||
'.editormd-markdown-textarea',
|
||||
'textarea[name="content"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (markdownTextarea) {
|
||||
setInputValue(markdownTextarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(markdownTextarea.value)) {
|
||||
return { success: true, method: 'markdown-textarea' }
|
||||
}
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '51CTO 未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectCto51Task(tab, helpers) {
|
||||
const currentState = inspectCto51TaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'blog.51cto.com'
|
||||
&& /^\/u_\d+\/\d+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const feedbackText = Array.from(document.querySelectorAll(
|
||||
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
|
||||
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectCto51TaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCto51Content(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取 51CTO 编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillCto51Content,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '51CTO 内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充 51CTO 编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
CTO51Platform,
|
||||
fillCto51Content,
|
||||
inspectCto51Task,
|
||||
inspectCto51TaskState,
|
||||
parseCto51PublishedUrl,
|
||||
syncCto51Content,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 豆瓣平台配置
|
||||
const DoubanPlatform = {
|
||||
id: 'douban',
|
||||
name: 'Douban',
|
||||
icon: 'https://cdn.simpleicons.org/douban/07C160',
|
||||
url: 'https://www.douban.com',
|
||||
publishUrl: 'https://www.douban.com/',
|
||||
title: '豆瓣',
|
||||
type: 'douban',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { DoubanPlatform }
|
||||
@@ -0,0 +1,14 @@
|
||||
// 抖音创作者平台配置
|
||||
const DouyinPlatform = {
|
||||
id: 'douyin',
|
||||
name: 'Douyin',
|
||||
icon: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/yvahlyj_upfbvk_zlp/ljhwZthlaukjlkulzlp/pc_creator/favicon_v2_7145ff0.ico',
|
||||
url: 'https://creator.douyin.com/',
|
||||
publishUrl:
|
||||
'https://creator.douyin.com/creator-micro/content/post/article?default-tab=5&enter_from=publish_page&media_type=article&type=new',
|
||||
title: '抖音',
|
||||
type: 'douyin',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { DouyinPlatform }
|
||||
@@ -0,0 +1,9 @@
|
||||
// 电子发烧友平台配置
|
||||
|
||||
export const ElecfansPlatform = {
|
||||
id: 'elecfans',
|
||||
name: '电子发烧友',
|
||||
icon: 'https://www.elecfans.com/favicon.ico',
|
||||
publishUrl: 'https://www.elecfans.com/d/article/md/',
|
||||
loginUrl: 'https://bbs.elecfans.com/member.php?mod=logging&action=login',
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 华为云开发者博客平台配置
|
||||
const HuaweiCloudPlatform = {
|
||||
id: 'huaweicloud',
|
||||
name: 'HuaweiCloud',
|
||||
icon: 'https://www.huaweicloud.com/favicon.ico',
|
||||
url: 'https://bbs.huaweicloud.com/blogs/article',
|
||||
publishUrl: 'https://bbs.huaweicloud.com/blogs/article',
|
||||
title: '华为云开发者博客',
|
||||
type: 'huaweicloud',
|
||||
}
|
||||
|
||||
export { HuaweiCloudPlatform }
|
||||
@@ -0,0 +1,13 @@
|
||||
// 华为开发者文章平台配置
|
||||
const HuaweiDevPlatform = {
|
||||
id: 'huaweidev',
|
||||
name: 'HuaweiDev',
|
||||
icon: 'https://developer.huawei.com/favicon.ico',
|
||||
url: 'https://developer.huawei.com/consumer/cn/',
|
||||
publishUrl: 'https://developer.huawei.com/consumer/cn/blog/create',
|
||||
title: '华为开发者文章',
|
||||
type: 'huaweidev',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { HuaweiDevPlatform }
|
||||
@@ -0,0 +1,202 @@
|
||||
// 平台配置汇总
|
||||
// 从 @cose/detection 导入登录检测配置
|
||||
import { LOGIN_CHECK_CONFIG } from '../../detection/src/configs.js'
|
||||
|
||||
// 平台元数据和同步函数从各平台文件导入
|
||||
import { CSDNPlatform, inspectCsdnTask, syncCsdnContent } from './csdn.js'
|
||||
import { inspectJuejinTaskState, JuejinPlatform, syncJuejinContent } from './juejin.js'
|
||||
import { WechatPlatform, syncWechatContent } from './wechat.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 {
|
||||
inspectOSChinaTask,
|
||||
OSChinaPlatform,
|
||||
syncOSChinaContent,
|
||||
} from './oschina.js'
|
||||
import {
|
||||
inspectQiehaoTask,
|
||||
QiehaoPlatform,
|
||||
syncQiehaoContent,
|
||||
} from './qiehao.js'
|
||||
import {
|
||||
CTO51Platform,
|
||||
inspectCto51Task,
|
||||
syncCto51Content,
|
||||
} from './cto51.js'
|
||||
import {
|
||||
InfoQPlatform,
|
||||
inspectInfoQTask,
|
||||
syncInfoQContent,
|
||||
} from './infoq.js'
|
||||
import {
|
||||
inspectJianshuTask,
|
||||
JianshuPlatform,
|
||||
syncJianshuContent,
|
||||
} from './jianshu.js'
|
||||
import {
|
||||
BaijiahaoPlatform,
|
||||
inspectBaijiahaoTask,
|
||||
syncBaijiahaoContent,
|
||||
} from './baijiahao.js'
|
||||
import {
|
||||
inspectWangyihaoTask,
|
||||
WangyihaoPlatform,
|
||||
syncWangyihaoContent,
|
||||
} from './wangyihao.js'
|
||||
import {
|
||||
inspectTencentCloudTask,
|
||||
TencentCloudPlatform,
|
||||
syncTencentCloudContent,
|
||||
} from './tencentcloud.js'
|
||||
import { MediumPlatform } from './medium.js'
|
||||
import { SspaiPlatform } from './sspai.js'
|
||||
import { inspectSohuTask, SohuPlatform, syncSohuContent } from './sohu.js'
|
||||
import { BilibiliPlatform } from './bilibili.js'
|
||||
import { WeiboPlatform } from './weibo.js'
|
||||
import {
|
||||
AliyunPlatform,
|
||||
inspectAliyunTask,
|
||||
syncAliyunContent,
|
||||
} from './aliyun.js'
|
||||
import { HuaweiCloudPlatform } from './huaweicloud.js'
|
||||
import { HuaweiDevPlatform } from './huaweidev.js'
|
||||
import { TwitterPlatform } from './twitter.js'
|
||||
import { QianfanPlatform } from './qianfan.js'
|
||||
import { AlipayOpenPlatform } from './alipayopen.js'
|
||||
import { ModelScopePlatform } from './modelscope.js'
|
||||
import { VolcenginePlatform } from './volcengine.js'
|
||||
import { DouyinPlatform } from './douyin.js'
|
||||
import { XiaohongshuPlatform } from './xiaohongshu.js'
|
||||
import { ElecfansPlatform } from './elecfans.js'
|
||||
import { DoubanPlatform } from './douban.js'
|
||||
|
||||
const AUTO_FILL_PLATFORM_IDS = new Set([
|
||||
'csdn',
|
||||
'wechat',
|
||||
'juejin',
|
||||
'jianshu',
|
||||
'zhihu',
|
||||
'toutiao',
|
||||
'baijiahao',
|
||||
'wangyi',
|
||||
'sohu',
|
||||
'infoq',
|
||||
'tencentcloud',
|
||||
'aliyun',
|
||||
'segmentfault',
|
||||
'51cto',
|
||||
'oschina',
|
||||
'qiehao',
|
||||
])
|
||||
|
||||
const AUTO_FILL_UNAVAILABLE_MESSAGE = '该平台尚未完成受控自动填充验证,请在 AiToEarn 中生成平台稿后手动复制。'
|
||||
|
||||
// 合并平台配置
|
||||
const PLATFORM_DEFINITIONS = [
|
||||
CSDNPlatform,
|
||||
JuejinPlatform,
|
||||
WechatPlatform,
|
||||
ZhihuPlatform,
|
||||
ToutiaoPlatform,
|
||||
SegmentFaultPlatform,
|
||||
CnblogsPlatform,
|
||||
OSChinaPlatform,
|
||||
QiehaoPlatform,
|
||||
CTO51Platform,
|
||||
InfoQPlatform,
|
||||
JianshuPlatform,
|
||||
BaijiahaoPlatform,
|
||||
WangyihaoPlatform,
|
||||
TencentCloudPlatform,
|
||||
MediumPlatform,
|
||||
SspaiPlatform,
|
||||
SohuPlatform,
|
||||
BilibiliPlatform,
|
||||
WeiboPlatform,
|
||||
AliyunPlatform,
|
||||
HuaweiCloudPlatform,
|
||||
HuaweiDevPlatform,
|
||||
TwitterPlatform,
|
||||
QianfanPlatform,
|
||||
AlipayOpenPlatform,
|
||||
ModelScopePlatform,
|
||||
VolcenginePlatform,
|
||||
DouyinPlatform,
|
||||
XiaohongshuPlatform,
|
||||
ElecfansPlatform,
|
||||
DoubanPlatform,
|
||||
]
|
||||
|
||||
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,
|
||||
juejin: syncJuejinContent,
|
||||
jianshu: syncJianshuContent,
|
||||
wechat: syncWechatContent,
|
||||
zhihu: syncZhihuContent,
|
||||
toutiao: syncToutiaoContent,
|
||||
baijiahao: syncBaijiahaoContent,
|
||||
wangyi: syncWangyihaoContent,
|
||||
sohu: syncSohuContent,
|
||||
infoq: syncInfoQContent,
|
||||
tencentcloud: syncTencentCloudContent,
|
||||
aliyun: syncAliyunContent,
|
||||
segmentfault: syncSegmentFaultContent,
|
||||
'51cto': syncCto51Content,
|
||||
oschina: syncOSChinaContent,
|
||||
qiehao: syncQiehaoContent,
|
||||
}
|
||||
|
||||
const INSPECT_HANDLERS = {
|
||||
csdn: inspectCsdnTask,
|
||||
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
|
||||
jianshu: inspectJianshuTask,
|
||||
zhihu: inspectZhihuTask,
|
||||
toutiao: inspectToutiaoTask,
|
||||
baijiahao: inspectBaijiahaoTask,
|
||||
wangyi: inspectWangyihaoTask,
|
||||
sohu: inspectSohuTask,
|
||||
infoq: inspectInfoQTask,
|
||||
tencentcloud: inspectTencentCloudTask,
|
||||
aliyun: inspectAliyunTask,
|
||||
segmentfault: inspectSegmentFaultTask,
|
||||
'51cto': inspectCto51Task,
|
||||
oschina: inspectOSChinaTask,
|
||||
qiehao: inspectQiehaoTask,
|
||||
}
|
||||
|
||||
// 导出
|
||||
export {
|
||||
AUTO_FILL_PLATFORM_IDS,
|
||||
INSPECT_HANDLERS,
|
||||
PLATFORMS,
|
||||
LOGIN_CHECK_CONFIG,
|
||||
SYNC_HANDLERS,
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
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',
|
||||
publishUrl: 'https://xie.infoq.cn/draft/write',
|
||||
createDraftApi: 'https://xie.infoq.cn/api/v1/draft/create',
|
||||
title: 'InfoQ',
|
||||
type: 'infoq',
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
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 标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || !['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开 InfoQ,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
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 编辑器,请等待保存完成或手动保存草稿',
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前 InfoQ 页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfoQDraftCreation(response) {
|
||||
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
|
||||
return normalizeString(data?.id)
|
||||
|| normalizeString(data?.draftId)
|
||||
|| normalizeString(data?.draft_id)
|
||||
}
|
||||
|
||||
async function createInfoQDraft() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/draft/create', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
const body = await response.json().catch(() => undefined)
|
||||
const draftId = body?.data?.id || body?.data?.draftId || body?.data?.draft_id
|
||||
if (!response.ok || body?.code !== 0 || !draftId) {
|
||||
return {
|
||||
success: false,
|
||||
error: body?.message || body?.msg || 'InfoQ 未返回新建草稿 ID',
|
||||
}
|
||||
}
|
||||
return { success: true, draftId: String(draftId) }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'InfoQ 创建草稿失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillInfoQContent(title, markdown, html, plainText) {
|
||||
const contentToFill = markdown || ''
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectEditorContents(editor) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
|
||||
if (input.isContentEditable) {
|
||||
selectEditorContents(input)
|
||||
const inserted = document.execCommand?.('insertText', false, value)
|
||||
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
|
||||
} else {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
}
|
||||
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
function getVueCandidates(editor) {
|
||||
const root = editor.closest?.('.gk-editor') || document.querySelector('.gk-editor')
|
||||
return [
|
||||
root?.__vue__,
|
||||
root?.__vueParentComponent?.proxy,
|
||||
editor.__vue__,
|
||||
editor.__vueParentComponent?.proxy,
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
function dispatchHtmlPaste(editor) {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
|
||||
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', plainText)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'.title-input input',
|
||||
'.title-input textarea',
|
||||
'input.article-title',
|
||||
'[contenteditable="true"][placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到 InfoQ 标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
|
||||
if (normalizeText(receivedTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: 'InfoQ 未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.gk-editor [contenteditable="true"]',
|
||||
'.gk-editor .ProseMirror',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
], titleInput)
|
||||
if (!editor) return { success: false, error: '未找到 InfoQ 正文编辑器' }
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
for (const candidate of getVueCandidates(editor)) {
|
||||
for (const method of [candidate.readMarkdown, candidate.editorAPI?.readMarkdown]) {
|
||||
if (typeof method !== 'function') continue
|
||||
try {
|
||||
await Promise.resolve(method.call(method === candidate.readMarkdown ? candidate : candidate.editorAPI, contentToFill))
|
||||
await sleep(800)
|
||||
if (hasExpectedContent(editor)) {
|
||||
return { success: true, method: 'markdown-api', imageCount: editor.querySelectorAll('img').length }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchHtmlPaste(editor)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: plainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, html)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: plainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
if (!hasExpectedContent(editor)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'InfoQ 未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'paste-html',
|
||||
imageCount: editor.querySelectorAll('img').length,
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectInfoQTask(tab, helpers) {
|
||||
const currentState = inspectInfoQTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return ['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)
|
||||
&& /^\/article\/[A-Za-z0-9]+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const visibleText = document.body?.innerText || ''
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|内容已保存|保存成功/.test(visibleText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectInfoQTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncInfoQContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取 InfoQ 编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const created = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: createInfoQDraft,
|
||||
world: 'MAIN',
|
||||
})
|
||||
const draft = created?.[0]?.result
|
||||
if (!draft?.success || !draft.draftId) {
|
||||
return {
|
||||
success: false,
|
||||
message: draft?.error || 'InfoQ 创建草稿失败,请确认当前 Chrome 已登录创作平台',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
const draftTab = await helpers.chrome.tabs.update(tab.id, {
|
||||
url: `https://xie.infoq.cn/draft/${encodeURIComponent(draft.draftId)}`,
|
||||
})
|
||||
await helpers.waitForTab(draftTab.id)
|
||||
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: draftTab.id },
|
||||
func: fillInfoQContent,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || 'InfoQ 内容填充失败',
|
||||
tabId: draftTab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已创建并填充 InfoQ 草稿,请检查后等待保存完成或手动发布',
|
||||
tabId: draftTab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createInfoQDraft,
|
||||
fillInfoQContent,
|
||||
InfoQPlatform,
|
||||
inspectInfoQTask,
|
||||
inspectInfoQTaskState,
|
||||
parseInfoQDraftCreation,
|
||||
parseInfoQPublishedUrl,
|
||||
syncInfoQContent,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// 简书平台配置
|
||||
const JianshuPlatform = {
|
||||
id: 'jianshu',
|
||||
name: 'Jianshu',
|
||||
icon: 'https://www.jianshu.com/favicon.ico',
|
||||
url: 'https://www.jianshu.com',
|
||||
publishUrl: 'https://www.jianshu.com/writer',
|
||||
title: '简书',
|
||||
type: 'jianshu',
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function inspectJianshuTaskState(rawUrl, publicUrl) {
|
||||
const published = parseJianshuPublishedUrl(publicUrl) || parseJianshuPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取简书标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || !['jianshu.com', 'www.jianshu.com'].includes(url.hostname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开简书,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
const draft = url.hash.match(/^#\/notebooks\/([^/]+)\/notes\/([^/?#]+)/)
|
||||
if (url.pathname === '/writer' && draft?.[2]) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draft[2],
|
||||
message: '已确认简书草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: url.pathname === '/writer'
|
||||
? '简书编辑器尚未进入本次任务文章,请重新打开并填充'
|
||||
: '当前简书页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectJianshuTask(tab, helpers) {
|
||||
const currentState = inspectJianshuTaskState(tab?.url || '')
|
||||
if (currentState.status !== 'draft_saved' || !tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const links = Array.from(document.querySelectorAll('a[href]'))
|
||||
const publicLink = links.find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const isPublicArticle = ['jianshu.com', 'www.jianshu.com'].includes(url.hostname)
|
||||
&& /^\/p\/[^/]+\/?$/.test(url.pathname)
|
||||
const isCurrentArticleAction = link.target === '_blank'
|
||||
|| /查看文章|阅读文章|已发布/.test(link.textContent || '')
|
||||
return isPublicArticle && isCurrentArticleAction
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return publicLink ? new URL(publicLink.getAttribute('href'), location.href).href : undefined
|
||||
},
|
||||
})
|
||||
return inspectJianshuTaskState(tab.url || '', result?.[0]?.result)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
function fillJianshuContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
input.focus()
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = title
|
||||
? await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input._24i7u',
|
||||
'input[class*="title"]',
|
||||
])
|
||||
: null
|
||||
if (title) {
|
||||
if (!titleInput) return { success: false, error: '未找到简书标题输入框' }
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeContent(titleInput.value) !== normalizeContent(title)) {
|
||||
return { success: false, error: '简书未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = await waitForElement([
|
||||
'textarea#arthur-editor',
|
||||
'textarea._3swFR',
|
||||
], titleInput, 5000)
|
||||
if (!editor) return { success: false, error: '未找到简书 Markdown 编辑器' }
|
||||
|
||||
setInputValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (normalizeContent(editor.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '简书未确认接收正文' }
|
||||
}
|
||||
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function syncJianshuContent(tab, content, helpers) {
|
||||
const { chrome, waitForTab } = helpers
|
||||
|
||||
const notebooksResponse = await fetch('https://www.jianshu.com/author/notebooks', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!notebooksResponse.ok) {
|
||||
return { success: false, message: '简书文集读取失败,请确认当前 Chrome 已登录', tabId: tab.id }
|
||||
}
|
||||
|
||||
const notebooks = await notebooksResponse.json()
|
||||
const notebookId = Array.isArray(notebooks) ? notebooks[0]?.id : undefined
|
||||
if (!notebookId) {
|
||||
return { success: false, message: '简书未找到文集,请先创建一个文集', tabId: tab.id }
|
||||
}
|
||||
|
||||
const createResponse = await fetch('https://www.jianshu.com/author/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notebook_id: String(notebookId),
|
||||
title: content.title || '无标题',
|
||||
at_bottom: false,
|
||||
}),
|
||||
})
|
||||
if (!createResponse.ok) {
|
||||
return { success: false, message: '简书草稿创建失败,请确认登录状态和文集权限', tabId: tab.id }
|
||||
}
|
||||
|
||||
const note = await createResponse.json()
|
||||
if (!note?.id) {
|
||||
return { success: false, message: '简书草稿创建结果缺少文章 ID', tabId: tab.id }
|
||||
}
|
||||
|
||||
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${note.id}`
|
||||
const updatedTab = await chrome.tabs.update(tab.id, { url: targetUrl })
|
||||
await waitForTab(updatedTab.id)
|
||||
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: updatedTab.id },
|
||||
func: fillJianshuContent,
|
||||
args: [content.title, content.markdown, content.body],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '简书内容填充失败',
|
||||
tabId: updatedTab.id,
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
return {
|
||||
success: true,
|
||||
message: '已同步到简书草稿',
|
||||
tabId: updatedTab.id,
|
||||
platformWorkId: String(note.id),
|
||||
url: targetUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
JianshuPlatform,
|
||||
fillJianshuContent,
|
||||
inspectJianshuTask,
|
||||
inspectJianshuTaskState,
|
||||
syncJianshuContent,
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 掘金平台配置
|
||||
const JuejinPlatform = {
|
||||
id: 'juejin',
|
||||
name: 'Juejin',
|
||||
icon: 'https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/static/favicons/favicon-32x32.png',
|
||||
url: 'https://juejin.cn',
|
||||
publishUrl: 'https://juejin.cn/editor/drafts/new',
|
||||
title: '掘金',
|
||||
type: 'juejin',
|
||||
}
|
||||
|
||||
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: '当前掘金页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillJuejinContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
input.focus()
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = title
|
||||
? await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'.title-input input',
|
||||
'input.title-input',
|
||||
])
|
||||
: null
|
||||
if (title) {
|
||||
if (!titleInput) return { success: false, error: '未找到掘金标题输入框' }
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '掘金未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const cmElement = await waitForElement(['.CodeMirror'], titleInput, 3000)
|
||||
if (cmElement && cmElement.CodeMirror) {
|
||||
cmElement.CodeMirror.setValue(contentToFill)
|
||||
cmElement.CodeMirror.focus?.()
|
||||
await sleep(300)
|
||||
if (normalizeContent(cmElement.CodeMirror.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
return { success: false, error: '掘金未确认接收正文' }
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.bytemd-body textarea',
|
||||
'.bytemd-editor textarea',
|
||||
'.bytemd-split .bytemd-body textarea',
|
||||
], titleInput, 4000)
|
||||
if (!textarea) return { success: false, error: '未找到掘金 Markdown 编辑器' }
|
||||
|
||||
setInputValue(textarea, contentToFill)
|
||||
await sleep(300)
|
||||
if (normalizeContent(textarea.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '掘金未确认接收正文' }
|
||||
}
|
||||
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
/**
|
||||
* 掘金同步处理器
|
||||
* @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 syncJuejinContent(tab, content, helpers) {
|
||||
const { chrome } = helpers
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillJuejinContent,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { JuejinPlatform, fillJuejinContent, inspectJuejinTaskState, syncJuejinContent }
|
||||
@@ -0,0 +1,61 @@
|
||||
// Medium 平台配置
|
||||
const MediumPlatform = {
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
icon: 'https://cdn.simpleicons.org/medium',
|
||||
url: 'https://medium.com',
|
||||
publishUrl: 'https://medium.com/new-story',
|
||||
title: 'Medium',
|
||||
type: 'medium',
|
||||
}
|
||||
|
||||
// Medium 登录检测配置
|
||||
// Medium 使用 sid 和 uid HttpOnly cookies 进行身份验证
|
||||
/**
|
||||
* Medium 内容填充函数
|
||||
* 流程:
|
||||
* 1. 等待编辑器加载
|
||||
* 2. 填充标题到 h3.graf--title
|
||||
* 3. 通过 paste 事件填充 HTML 内容到编辑器
|
||||
*/
|
||||
async function fillMediumContent(content, waitFor, setInputValue) {
|
||||
const { title, body, html } = content
|
||||
const htmlContent = html || body || ''
|
||||
|
||||
console.log('[COSE] Medium 开始同步...')
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 第一步:填充标题
|
||||
const titleEl = document.querySelector('h3.graf--title')
|
||||
if (titleEl && title) {
|
||||
titleEl.focus()
|
||||
titleEl.textContent = title
|
||||
titleEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
console.log('[COSE] Medium 标题填充成功')
|
||||
}
|
||||
|
||||
// 第二步:填充内容 - 使用 paste 事件
|
||||
const contentEl = document.querySelector('p.graf--p')
|
||||
if (contentEl && htmlContent) {
|
||||
contentEl.focus()
|
||||
|
||||
// 创建 DataTransfer 并设置 HTML 内容
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/html', htmlContent)
|
||||
dt.setData('text/plain', htmlContent.replace(/<[^>]*>/g, ''))
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
contentEl.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] Medium 内容填充成功')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { MediumPlatform, fillMediumContent }
|
||||
@@ -0,0 +1,15 @@
|
||||
// ModelScope 魔搭社区平台配置
|
||||
// 编辑器支持 Markdown,注入后需要点击"转为富文本"按钮
|
||||
|
||||
const ModelScopePlatform = {
|
||||
id: 'modelscope',
|
||||
name: 'ModelScope',
|
||||
icon: 'https://img.alicdn.com/imgextra/i4/O1CN01fvt4it25rEZU4Gjso_!!6000000007579-2-tps-128-128.png',
|
||||
url: 'https://modelscope.cn',
|
||||
publishUrl: 'https://modelscope.cn/learn/create',
|
||||
title: 'ModelScope 魔搭社区',
|
||||
type: 'modelscope',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { ModelScopePlatform }
|
||||
@@ -0,0 +1,317 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const OSChinaPlatform = {
|
||||
id: 'oschina',
|
||||
name: 'OSChina',
|
||||
icon: 'https://www.oschina.net/favicon.ico',
|
||||
url: 'https://www.oschina.net',
|
||||
publishUrl: 'https://my.oschina.net/blog/ai-write',
|
||||
title: '开源中国',
|
||||
type: 'oschina',
|
||||
}
|
||||
|
||||
function isOSChinaEditorUrl(url) {
|
||||
return url.protocol === 'https:'
|
||||
&& url.hostname === 'my.oschina.net'
|
||||
&& /^\/(?:blog\/ai-write|u\/\d+\/blog\/ai-write|u\/draft\/new)\/?$/.test(url.pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开源中国的公开文章地址尚未经过真实创作者账号验证。
|
||||
* 因此只确认编辑器和草稿状态,避免把未知页面误记为已发布。
|
||||
*/
|
||||
function inspectOSChinaTaskState(rawUrl, draftSaved = false) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取开源中国编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (!isOSChinaEditorUrl(url)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前开源中国页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认开源中国编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到开源中国编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
function fillOSChinaContent(title, markdown, html, plainText) {
|
||||
const markdownContent = markdown || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || markdownContent)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setRichTextValue(editor, nextHtml, nextPlainText) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedMarkdown(value) {
|
||||
return normalizeContent(value) === normalizeContent(markdownContent)
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const imageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function switchToMarkdownEditor() {
|
||||
const switchButton = Array.from(document.querySelectorAll('button, [role="button"], a'))
|
||||
.find(element => {
|
||||
const text = element.textContent?.replace(/\s+/g, '') || ''
|
||||
return /切换到(?:Markdown|MD)编辑器|(?:Markdown|MD)编辑器/.test(text)
|
||||
})
|
||||
|| document.querySelector('.editor-switch-btn')
|
||||
if (!switchButton) return
|
||||
|
||||
switchButton.click()
|
||||
await sleep(500)
|
||||
const confirmButton = Array.from(document.querySelectorAll('button')).find(button =>
|
||||
/确定切换|确认切换/.test(button.textContent?.trim() || ''),
|
||||
)
|
||||
if (confirmButton) {
|
||||
confirmButton.click()
|
||||
await sleep(1200)
|
||||
}
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[name="title"]',
|
||||
'.title-input input',
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到开源中国标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(100)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '开源中国未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!markdownContent) return { success: true, method: 'title-only' }
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
|
||||
const codeMirrorEditor = codeMirror?.CodeMirror
|
||||
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
|
||||
codeMirrorEditor.focus()
|
||||
codeMirrorEditor.setValue(markdownContent)
|
||||
await sleep(400)
|
||||
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'.editormd-markdown-textarea',
|
||||
'.markdown-editor textarea',
|
||||
'textarea[name="content"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea',
|
||||
], 4000)
|
||||
if (textarea) {
|
||||
setInputValue(textarea, markdownContent)
|
||||
await sleep(300)
|
||||
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: 'markdown-textarea' }
|
||||
}
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.cke_editable[contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'.editor [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
], 4000)
|
||||
if (richEditor) {
|
||||
setRichTextValue(richEditor, html, plainText)
|
||||
await sleep(500)
|
||||
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '开源中国未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectOSChinaTask(tab, helpers) {
|
||||
const currentState = inspectOSChinaTaskState(tab?.url || '')
|
||||
if (!tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const feedbackText = Array.from(document.querySelectorAll(
|
||||
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
|
||||
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
|
||||
return {
|
||||
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
|
||||
}
|
||||
},
|
||||
})
|
||||
return inspectOSChinaTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOSChinaContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取开源中国编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillOSChinaContent,
|
||||
args: [
|
||||
content.title,
|
||||
markdown,
|
||||
renderToutiaoMarkdown(markdown),
|
||||
renderToutiaoPlainText(markdown),
|
||||
],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '开源中国内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充开源中国编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
OSChinaPlatform,
|
||||
fillOSChinaContent,
|
||||
inspectOSChinaTask,
|
||||
inspectOSChinaTaskState,
|
||||
syncOSChinaContent,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// 百度千帆开发者社区平台配置
|
||||
// 编辑器支持 Markdown 自动转换功能
|
||||
|
||||
const QianfanPlatform = {
|
||||
id: 'qianfan',
|
||||
name: 'Qianfan',
|
||||
icon: 'https://bce.bdstatic.com/img/favicon.ico',
|
||||
url: 'https://qianfan.cloud.baidu.com/qianfandev',
|
||||
publishUrl: 'https://qianfan.cloud.baidu.com/qianfandev/topic/create',
|
||||
title: '百度云千帆',
|
||||
type: 'qianfan',
|
||||
}
|
||||
|
||||
// 千帆平台拦截函数
|
||||
// 在 MAIN world 中执行,拦截所有可能导致跳转到登录页的行为
|
||||
// 包括:fetch, XHR, sendBeacon, location 跳转, window.open, Navigation API, History API
|
||||
function qianfanIntercept() {
|
||||
if (!location.href.includes('qianfan.cloud.baidu.com')) return
|
||||
|
||||
const INTERCEPT_PATTERN = '/api/community/topic'
|
||||
const LOGIN_URL_PATTERN = 'login.bce.baidu.com'
|
||||
let blockedCount = 0
|
||||
|
||||
const FAKE_RESPONSE = JSON.stringify({
|
||||
success: true,
|
||||
status: 200,
|
||||
result: { id: 'cose-intercepted' },
|
||||
})
|
||||
|
||||
console.log('[COSE] 千帆拦截器开始安装...')
|
||||
|
||||
// ========== 拦截 fetch ==========
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = function (...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''
|
||||
const opts = args[1] || {}
|
||||
const method = (opts.method || args[0]?.method || 'GET').toUpperCase()
|
||||
|
||||
if (url.includes(INTERCEPT_PATTERN) && method === 'POST') {
|
||||
console.log('[COSE] 拦截 fetch POST:', url, '(已拦截', ++blockedCount, '个)')
|
||||
return Promise.resolve(
|
||||
new Response(FAKE_RESPONSE, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
}
|
||||
return originalFetch.apply(this, args)
|
||||
}
|
||||
|
||||
// ========== 拦截 XMLHttpRequest ==========
|
||||
const originalXHROpen = XMLHttpRequest.prototype.open
|
||||
const originalXHRSend = XMLHttpRequest.prototype.send
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
this._coseUrl = url
|
||||
this._coseMethod = (method || 'GET').toUpperCase()
|
||||
return originalXHROpen.call(this, method, url, ...rest)
|
||||
}
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
if (this._coseUrl?.includes(INTERCEPT_PATTERN) && this._coseMethod === 'POST') {
|
||||
console.log('[COSE] 拦截 XHR POST:', this._coseUrl, '(已拦截', ++blockedCount, '个)')
|
||||
const self = this
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4, configurable: true })
|
||||
Object.defineProperty(self, 'status', { get: () => 200, configurable: true })
|
||||
Object.defineProperty(self, 'statusText', { get: () => 'OK', configurable: true })
|
||||
Object.defineProperty(self, 'responseText', {
|
||||
get: () => FAKE_RESPONSE,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(self, 'response', { get: () => FAKE_RESPONSE, configurable: true })
|
||||
self.dispatchEvent(new Event('readystatechange'))
|
||||
self.dispatchEvent(new Event('load'))
|
||||
self.dispatchEvent(new Event('loadend'))
|
||||
if (typeof self.onreadystatechange === 'function') self.onreadystatechange()
|
||||
if (typeof self.onload === 'function') self.onload()
|
||||
}, 10)
|
||||
return
|
||||
}
|
||||
return originalXHRSend.call(this, body)
|
||||
}
|
||||
|
||||
// ========== 拦截 navigator.sendBeacon ==========
|
||||
const originalSendBeacon = navigator.sendBeacon?.bind(navigator)
|
||||
if (originalSendBeacon) {
|
||||
navigator.sendBeacon = function (url, data) {
|
||||
if (url?.includes(INTERCEPT_PATTERN)) {
|
||||
console.log('[COSE] 拦截 sendBeacon:', url, '(已拦截', ++blockedCount, '个)')
|
||||
return true
|
||||
}
|
||||
return originalSendBeacon(url, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 拦截 location 跳转到登录页 ==========
|
||||
const origAssign = window.location.assign.bind(window.location)
|
||||
const origReplace = window.location.replace.bind(window.location)
|
||||
|
||||
window.location.assign = function (url) {
|
||||
if (typeof url === 'string' && url.includes(LOGIN_URL_PATTERN)) {
|
||||
console.log('[COSE] 拦截 location.assign 跳转到登录页:', url)
|
||||
return
|
||||
}
|
||||
return origAssign(url)
|
||||
}
|
||||
|
||||
window.location.replace = function (url) {
|
||||
if (typeof url === 'string' && url.includes(LOGIN_URL_PATTERN)) {
|
||||
console.log('[COSE] 拦截 location.replace 跳转到登录页:', url)
|
||||
return
|
||||
}
|
||||
return origReplace(url)
|
||||
}
|
||||
|
||||
// ========== 拦截 window.open 到登录页 ==========
|
||||
const originalOpen = window.open
|
||||
window.open = function (url, ...rest) {
|
||||
if (typeof url === 'string' && url.includes(LOGIN_URL_PATTERN)) {
|
||||
console.log('[COSE] 拦截 window.open 跳转到登录页:', url)
|
||||
return null
|
||||
}
|
||||
return originalOpen.call(this, url, ...rest)
|
||||
}
|
||||
|
||||
// ========== 拦截 Navigation API ==========
|
||||
if (window.navigation) {
|
||||
window.navigation.addEventListener('navigate', e => {
|
||||
const destUrl = e.destination?.url || ''
|
||||
console.log('[COSE] Navigation API navigate 事件:', destUrl)
|
||||
if (destUrl.includes(LOGIN_URL_PATTERN)) {
|
||||
console.log('[COSE] 拦截 Navigation API 跳转到登录页')
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 拦截 History API ==========
|
||||
const origPushState = history.pushState.bind(history)
|
||||
const origReplaceState = history.replaceState.bind(history)
|
||||
|
||||
history.pushState = function (state, title, url) {
|
||||
if (typeof url === 'string' && url.includes(LOGIN_URL_PATTERN)) {
|
||||
console.log('[COSE] 拦截 pushState 跳转到登录页:', url)
|
||||
return
|
||||
}
|
||||
return origPushState(state, title, url)
|
||||
}
|
||||
|
||||
history.replaceState = function (state, title, url) {
|
||||
if (typeof url === 'string' && url.includes(LOGIN_URL_PATTERN)) {
|
||||
console.log('[COSE] 拦截 replaceState 跳转到登录页:', url)
|
||||
return
|
||||
}
|
||||
return origReplaceState(state, title, url)
|
||||
}
|
||||
|
||||
console.log('[COSE] 千帆拦截器安装完成(fetch/XHR/sendBeacon/location/navigation)')
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { QianfanPlatform, qianfanIntercept }
|
||||
@@ -0,0 +1,284 @@
|
||||
const QiehaoPlatform = {
|
||||
id: 'qiehao',
|
||||
name: 'Qiehao',
|
||||
icon: 'https://om.qq.com/favicon.ico',
|
||||
url: 'https://om.qq.com/',
|
||||
publishUrl: 'https://om.qq.com/main/creation/article',
|
||||
title: '企鹅号',
|
||||
type: 'qiehao',
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getQiehaoDraftId(url) {
|
||||
return normalizeString(
|
||||
url.searchParams.get('draftId')
|
||||
|| url.searchParams.get('draft_id')
|
||||
|| url.searchParams.get('articleId')
|
||||
|| url.searchParams.get('article_id'),
|
||||
)
|
||||
}
|
||||
|
||||
function parseQiehaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'page.om.qq.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/page\/([^/.]+)\.html$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认企鹅号文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectQiehaoTaskState(rawUrl, draftSaved = false) {
|
||||
const published = parseQiehaoPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取企鹅号编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'om.qq.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开企鹅号编辑器,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/main/creation/article') {
|
||||
const draftId = getQiehaoDraftId(url)
|
||||
if (draftSaved || draftId) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: draftId
|
||||
? '已确认企鹅号草稿地址'
|
||||
: '已确认企鹅号编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到企鹅号编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前企鹅号页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillQiehaoContent(title, html, plainText) {
|
||||
const contentHtml = html || ''
|
||||
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = contentHtml
|
||||
? htmlToComparableText(contentHtml)
|
||||
: normalizeComparableText(contentText)
|
||||
const expectedImageCount = Array.from(contentHtml.matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors
|
||||
.map(selector => document.querySelector(selector))
|
||||
.find(element => element && element.offsetParent !== null)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectContents(element) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditor(editor, nextHtml, nextText) {
|
||||
editor.focus()
|
||||
selectContents(editor)
|
||||
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedTitle(editor) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(editor?.textContent) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
|
||||
const actualImageCount = editor?.querySelectorAll?.('img').length || 0
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || actualImageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleEditor = await waitForElement([
|
||||
'.omui-inputautogrowing__inner[contenteditable="true"][data-placeholder*="请输入标题"]',
|
||||
])
|
||||
if (!titleEditor) return { success: false, error: '未找到企鹅号标题输入框' }
|
||||
if (title) {
|
||||
updateEditor(titleEditor, String(title).replace(/</g, '<'), title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleEditor)) return { success: false, error: '企鹅号未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentHtml) return { success: true, method: 'title-only' }
|
||||
|
||||
const articleEditor = await waitForElement([
|
||||
'.ProseMirror.ExEditor-basic[contenteditable="true"]',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
])
|
||||
if (!articleEditor) return { success: false, error: '未找到企鹅号正文编辑器' }
|
||||
|
||||
updateEditor(articleEditor, contentHtml, contentText)
|
||||
await sleep(500)
|
||||
if (!hasExpectedContent(articleEditor)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '企鹅号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, method: 'rich-text' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectQiehaoTask(tab, helpers) {
|
||||
const currentState = inspectQiehaoTaskState(tab?.url || '')
|
||||
if (!tab?.id) return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => ({
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|草稿保存成功/.test(
|
||||
document.body?.innerText || '',
|
||||
),
|
||||
}),
|
||||
})
|
||||
return inspectQiehaoTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncQiehaoContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取企鹅号编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const html = content.html || content.body || content.markdown || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillQiehaoContent,
|
||||
args: [content.title, html, content.plainText || html.replace(/<[^>]+>/g, ' ')],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '企鹅号内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充企鹅号文章编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fillQiehaoContent,
|
||||
inspectQiehaoTask,
|
||||
inspectQiehaoTaskState,
|
||||
parseQiehaoPublishedUrl,
|
||||
QiehaoPlatform,
|
||||
syncQiehaoContent,
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
const SegmentFaultPlatform = {
|
||||
id: 'segmentfault',
|
||||
name: 'SegmentFault',
|
||||
icon: 'https://fastly.jsdelivr.net/gh/bucketio/img16@main/2026/02/01/1769960912823-e037663a-7f65-414e-a114-ed86b4e86964.png',
|
||||
url: 'https://segmentfault.com',
|
||||
publishUrl: 'https://segmentfault.com/write',
|
||||
title: '思否',
|
||||
type: 'segmentfault',
|
||||
}
|
||||
|
||||
function parseSegmentFaultPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'segmentfault.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/a\/([^/?#]+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认思否文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectSegmentFaultTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseSegmentFaultPublishedUrl(publicUrl) || parseSegmentFaultPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取思否编辑器标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'segmentfault.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开思否,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname !== '/write') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前思否页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
const draftId = url.searchParams.get('draft')
|
||||
|| url.searchParams.get('draftId')
|
||||
|| url.searchParams.get('draft_id')
|
||||
|| url.searchParams.get('articleId')
|
||||
|| url.searchParams.get('article_id')
|
||||
|| url.searchParams.get('id')
|
||||
|
||||
if (draftId && draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: '已确认思否编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: draftId
|
||||
? '内容已填充到思否草稿编辑器,请检查后保存草稿或发布'
|
||||
: '内容已填充到思否新建编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
function fillSegmentFaultContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
for (const element of document.querySelectorAll(selector)) {
|
||||
if (element !== excludedElement && !element.disabled && element.offsetParent !== null) return element
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function setContentEditableValue(editor, value) {
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
const inserted = document.execCommand?.('insertText', false, value)
|
||||
if (!inserted || normalizeText(editor.textContent) !== normalizeText(value)) editor.textContent = value
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedContent(value) {
|
||||
return normalizeContent(value) === normalizeContent(contentToFill)
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'input#title',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'.title-input input',
|
||||
])
|
||||
if (title && !titleInput) return { success: false, error: '未找到思否标题输入框' }
|
||||
if (titleInput && title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) return { success: false, error: '思否未确认接收标题' }
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.CodeMirror',
|
||||
'.cm-editor .cm-content[contenteditable="true"]',
|
||||
'.editor-container textarea',
|
||||
'.editor-wrapper textarea',
|
||||
'[data-testid*="editor"] [contenteditable="true"]',
|
||||
'[contenteditable="true"]',
|
||||
'textarea',
|
||||
], titleInput)
|
||||
if (!editor) return { success: false, error: '未找到思否 Markdown 编辑器' }
|
||||
|
||||
if (editor.CodeMirror && typeof editor.CodeMirror.setValue === 'function') {
|
||||
editor.CodeMirror.setValue(contentToFill)
|
||||
editor.CodeMirror.focus?.()
|
||||
await sleep(500)
|
||||
if (hasExpectedContent(editor.CodeMirror.getValue?.())) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
} else if (editor instanceof HTMLTextAreaElement) {
|
||||
setInputValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (hasExpectedContent(editor.value)) return { success: true, method: 'textarea' }
|
||||
} else if (editor.isContentEditable) {
|
||||
setContentEditableValue(editor, contentToFill)
|
||||
await sleep(300)
|
||||
if (hasExpectedContent(editor.textContent)) return { success: true, method: 'contenteditable' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '思否未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectSegmentFaultTask(tab, helpers) {
|
||||
const currentState = inspectSegmentFaultTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'segmentfault.com'
|
||||
&& /^\/a\/[^/?#]+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const visibleText = document.body?.innerText || ''
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|文章已保存|保存成功/.test(visibleText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectSegmentFaultTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSegmentFaultContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取思否编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillSegmentFaultContent,
|
||||
args: [content.title, content.markdown || content.body || '', content.body || ''],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '思否内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充思否编辑器,请检查后手动保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
SegmentFaultPlatform,
|
||||
fillSegmentFaultContent,
|
||||
inspectSegmentFaultTask,
|
||||
inspectSegmentFaultTaskState,
|
||||
parseSegmentFaultPublishedUrl,
|
||||
syncSegmentFaultContent,
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const SohuPlatform = {
|
||||
id: 'sohu',
|
||||
name: 'Sohu',
|
||||
icon: 'https://statics.itc.cn/mp-new/icon/1.1/favicon.ico',
|
||||
url: 'https://mp.sohu.com',
|
||||
publishUrl: 'https://mp.sohu.com/mpfe/v4/main/content/article/create',
|
||||
title: '搜狐号',
|
||||
type: 'sohu',
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function inspectSohuTaskState(rawUrl, publicUrl, draftSaved = false) {
|
||||
const published = parseSohuPublishedUrl(publicUrl) || parseSohuPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取搜狐号标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'mp.sohu.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开搜狐号,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
/^\/mpfe\/v4\/main\/content\/article\/(?:create|edit)\/?$/.test(url.pathname)
|
||||
|| /^\/mpfe\/v4\/contentManagement\/news\/addarticle\/?$/.test(url.pathname)
|
||||
) {
|
||||
if (draftSaved) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
message: '已确认搜狐号编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到搜狐号编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前搜狐号页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillSohuContent(title, html, plainText) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function waitForCondition(getValue, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = getValue()
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const value = getValue()
|
||||
if (!value) return
|
||||
observer.disconnect()
|
||||
resolve(value)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(getValue())
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function selectEditorContents(editor) {
|
||||
const ownerDocument = editor.ownerDocument
|
||||
const selection = ownerDocument.defaultView?.getSelection()
|
||||
const range = ownerDocument.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function setTitleValue(input, value) {
|
||||
input.focus()
|
||||
|
||||
if (input.isContentEditable) {
|
||||
selectEditorContents(input)
|
||||
const inserted = input.ownerDocument.execCommand?.('insertText', false, value)
|
||||
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
|
||||
} else {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
}
|
||||
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function findUeditor() {
|
||||
const instances = window.UE?.instants || window.UE?.instances
|
||||
if (!instances || typeof instances !== 'object') return undefined
|
||||
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
|
||||
}
|
||||
|
||||
function findEditorIframe() {
|
||||
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
|
||||
const body = iframe.contentDocument?.body
|
||||
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
function findContentEditor(titleInput) {
|
||||
return Array.from(document.querySelectorAll('[contenteditable="true"], .edui-body')).find(element => {
|
||||
return element !== titleInput && !titleInput.contains(element) && !element.contains(titleInput)
|
||||
})
|
||||
}
|
||||
|
||||
function hasExpectedContent(text, imageCount) {
|
||||
const editorText = normalizeComparableText(text)
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
function setEditableHtml(editor, nextHtml, nextPlainText) {
|
||||
const ownerDocument = editor.ownerDocument
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
|
||||
if (!inserted) editor.innerHTML = nextHtml
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: nextPlainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForCondition(() => {
|
||||
return [
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'.title-input input',
|
||||
'.title-input textarea',
|
||||
'[contenteditable="true"][placeholder*="标题"]',
|
||||
].flatMap(selector => Array.from(document.querySelectorAll(selector))).find(Boolean)
|
||||
})
|
||||
if (!titleInput) return { success: false, error: '未找到搜狐号标题输入框' }
|
||||
if (title) {
|
||||
setTitleValue(titleInput, title)
|
||||
await sleep(200)
|
||||
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
|
||||
if (normalizeText(receivedTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: '搜狐号未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return { success: true, method: 'title-only' }
|
||||
|
||||
const ueditor = await waitForCondition(findUeditor, 7000)
|
||||
if (ueditor) {
|
||||
try {
|
||||
ueditor.setContent(html)
|
||||
ueditor.fireEvent?.('contentchange')
|
||||
ueditor.fireEvent?.('selectionchange')
|
||||
await sleep(500)
|
||||
const serialized = ueditor.getContent?.() || ''
|
||||
const text = ueditor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
|
||||
const imageCount = (serialized.match(/<img\b/gi) || []).length
|
||||
if (hasExpectedContent(text, imageCount)) {
|
||||
return { success: true, method: 'ueditor', imageCount }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const iframe = await waitForCondition(findEditorIframe, 7000)
|
||||
const iframeBody = iframe?.contentDocument?.body
|
||||
if (iframeBody?.isContentEditable) {
|
||||
setEditableHtml(iframeBody, html, plainText)
|
||||
await sleep(500)
|
||||
const imageCount = iframeBody.querySelectorAll('img').length
|
||||
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
|
||||
return { success: true, method: 'iframe', imageCount }
|
||||
}
|
||||
}
|
||||
|
||||
const contentEditor = await waitForCondition(() => findContentEditor(titleInput), 7000)
|
||||
if (contentEditor) {
|
||||
setEditableHtml(contentEditor, html, plainText)
|
||||
await sleep(500)
|
||||
const imageCount = contentEditor.querySelectorAll('img').length
|
||||
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
|
||||
return { success: true, method: 'contenteditable', imageCount }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '搜狐号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectSohuTask(tab, helpers) {
|
||||
const currentState = inspectSohuTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'www.sohu.com'
|
||||
&& /^\/a\/\d+(?:_\d+)?\/?(?:\.html)?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const visibleText = document.body?.innerText || ''
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
visibleText,
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功/.test(visibleText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
const state = inspectSohuTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
if (state.status === 'filled' && /发布成功|已发布/.test(observed?.visibleText || '')) {
|
||||
return {
|
||||
...state,
|
||||
status: 'publication_uncertain',
|
||||
message: '搜狐号页面显示发布完成,但未找到公开文章地址,请人工核对后回写结果',
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSohuContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取搜狐号编辑器标签页' }
|
||||
|
||||
const markdown = content.markdown || content.body || ''
|
||||
await helpers.waitForTab(tab.id)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillSohuContent,
|
||||
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '搜狐号内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已打开并填充搜狐号编辑器,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fillSohuContent,
|
||||
inspectSohuTask,
|
||||
inspectSohuTaskState,
|
||||
parseSohuPublishedUrl,
|
||||
SohuPlatform,
|
||||
syncSohuContent,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 少数派平台配置
|
||||
const SspaiPlatform = {
|
||||
id: 'sspai',
|
||||
name: 'Sspai',
|
||||
icon: 'https://cdn-static.sspai.com/favicon/sspai.ico',
|
||||
url: 'https://sspai.com',
|
||||
loginUrl: 'https://sspai.com/write',
|
||||
publishUrl: 'https://sspai.com/write',
|
||||
title: '少数派',
|
||||
type: 'sspai',
|
||||
}
|
||||
|
||||
// 少数派内容填充函数(备用,主要使用剪贴板方式)
|
||||
async function fillSspaiContent(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
// 1. 填充标题 - 少数派使用 textbox
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const titleInput =
|
||||
document.querySelector('textarea[placeholder*="标题"]') ||
|
||||
document.querySelector('input[placeholder*="标题"]')
|
||||
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
// 使用 native setter 来绕过 React/Vue 的受控组件
|
||||
const nativeSetter =
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set ||
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.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] 少数派标题填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 少数派未找到标题输入框')
|
||||
}
|
||||
|
||||
// 2. 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// 3. 填充正文内容
|
||||
// 少数派使用 ProseMirror 富文本编辑器
|
||||
const editor =
|
||||
document.querySelector('.ProseMirror') || document.querySelector('[contenteditable="true"]')
|
||||
|
||||
if (editor) {
|
||||
editor.focus()
|
||||
editor.innerHTML = contentToFill.replace(/\n/g, '<br>')
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
console.log('[COSE] 少数派编辑器填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 少数派未找到编辑器元素')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { SspaiPlatform, fillSspaiContent }
|
||||
@@ -0,0 +1,396 @@
|
||||
const TencentCloudPlatform = {
|
||||
id: 'tencentcloud',
|
||||
name: 'TencentCloud',
|
||||
icon: 'https://cloudcache.tencent-cloud.com/qcloud/favicon.ico',
|
||||
url: 'https://cloud.tencent.com/developer',
|
||||
publishUrl: 'https://cloud.tencent.com/developer/article/write-new',
|
||||
title: '腾讯云开发者社区',
|
||||
type: 'tencentcloud',
|
||||
autoFillSupported: true,
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function parseTencentCloudPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://cloud.tencent.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'cloud.tencent.com') return null
|
||||
|
||||
const match = url.pathname.match(/^\/developer\/article\/(\d+)\/?$/)
|
||||
if (!match?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: match[1],
|
||||
message: '已确认腾讯云开发者社区文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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: '当前标签页已经离开腾讯云开发者社区,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
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: '当前腾讯云开发者社区页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillTencentCloudContent(title, markdown, html, plainText) {
|
||||
const contentToFill = markdown || plainText || ''
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeContent = value => String(value || '').replace(/\r\n?/g, '\n')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedRichText = html
|
||||
? htmlToComparableText(html)
|
||||
: normalizeComparableText(plainText || contentToFill)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
|
||||
const sampleStarts = expectedRichText.length > sampleLength
|
||||
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedRichText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors) {
|
||||
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
input.focus()
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function selectEditorContents(editor) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function getRichEditorSnapshot(editor) {
|
||||
return {
|
||||
text: normalizeComparableText(editor?.innerText || editor?.textContent),
|
||||
imageCount: editor?.querySelectorAll?.('img').length || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function hasExpectedTitle(input) {
|
||||
const expectedTitle = normalizeText(title)
|
||||
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
|
||||
}
|
||||
|
||||
function hasExpectedRichText(editor) {
|
||||
const snapshot = getRichEditorSnapshot(editor)
|
||||
const receivedText = expectedRichText.length === 0 || (
|
||||
snapshot.text.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => snapshot.text.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || snapshot.imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
function pasteRichText(editor) {
|
||||
if (!html || typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
|
||||
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', plainText || contentToFill)
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
function insertRichTextFallback(editor) {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
if (html) document.execCommand?.('insertHTML', false, html)
|
||||
else document.execCommand?.('insertText', false, plainText || contentToFill)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: plainText || contentToFill,
|
||||
inputType: html ? 'insertFromPaste' : 'insertText',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function getCodeMirror() {
|
||||
const element = document.querySelector('.CodeMirror')
|
||||
return element?.CodeMirror
|
||||
}
|
||||
|
||||
async function switchToMarkdownEditor() {
|
||||
const switchButton = Array.from(document.querySelectorAll('.header-btn, button, [role="button"]'))
|
||||
.find(element => {
|
||||
const text = element.textContent?.replace(/\s+/g, '') || ''
|
||||
return text.includes('切换') && /MD|Markdown/i.test(text)
|
||||
})
|
||||
if (!switchButton) return
|
||||
|
||||
switchButton.click()
|
||||
await sleep(900)
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'.article-title textarea',
|
||||
'.article-title input',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到腾讯云开发者社区标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (!hasExpectedTitle(titleInput)) {
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentToFill) return { success: true, method: 'title-only' }
|
||||
|
||||
const richEditor = await waitForElement([
|
||||
'#cdc-rich-editor.tiptap.ProseMirror[contenteditable="true"]',
|
||||
'#cdc-rich-editor.tiptap.ProseMirror',
|
||||
'.tiptap.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
], 1200)
|
||||
if (richEditor) {
|
||||
pasteRichText(richEditor)
|
||||
await sleep(350)
|
||||
if (!hasExpectedRichText(richEditor)) {
|
||||
insertRichTextFallback(richEditor)
|
||||
await sleep(350)
|
||||
}
|
||||
if (!hasExpectedRichText(richEditor)) {
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收富文本正文' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
method: 'rich-text',
|
||||
imageCount: getRichEditorSnapshot(richEditor).imageCount,
|
||||
}
|
||||
}
|
||||
|
||||
await switchToMarkdownEditor()
|
||||
const codeMirror = await waitForElement(['.CodeMirror'], 4500)
|
||||
const editor = codeMirror?.CodeMirror || getCodeMirror()
|
||||
if (editor && typeof editor.setValue === 'function') {
|
||||
editor.focus()
|
||||
editor.setValue(contentToFill)
|
||||
await sleep(250)
|
||||
if (normalizeContent(editor.getValue?.()) === normalizeContent(contentToFill)) {
|
||||
return { success: true, method: 'codemirror' }
|
||||
}
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
|
||||
}
|
||||
|
||||
const textarea = await waitForElement([
|
||||
'textarea[placeholder*="正文"]',
|
||||
'.markdown-editor textarea',
|
||||
'.editor-container textarea',
|
||||
], 3000)
|
||||
if (!textarea) return { success: false, error: '未找到腾讯云开发者社区 Markdown 编辑器' }
|
||||
|
||||
setInputValue(textarea, contentToFill)
|
||||
await sleep(250)
|
||||
if (normalizeContent(textarea.value) !== normalizeContent(contentToFill)) {
|
||||
return { success: false, error: '腾讯云开发者社区未确认接收正文' }
|
||||
}
|
||||
|
||||
return { success: true, method: 'textarea' }
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectTencentCloudTask(tab, helpers) {
|
||||
const currentState = inspectTencentCloudTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href'), location.href)
|
||||
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
|
||||
return url.hostname === 'cloud.tencent.com'
|
||||
&& /^\/developer\/article\/\d+\/?$/.test(url.pathname)
|
||||
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const visibleText = document.body?.innerText || ''
|
||||
return {
|
||||
publicUrl: publicLink
|
||||
? new URL(publicLink.getAttribute('href'), location.href).href
|
||||
: undefined,
|
||||
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|内容已保存|保存成功/.test(visibleText),
|
||||
}
|
||||
},
|
||||
})
|
||||
const observed = result?.[0]?.result
|
||||
return inspectTencentCloudTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
|
||||
} catch {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTencentCloudContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取腾讯云开发者社区编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillTencentCloudContent,
|
||||
args: [content.title, markdown, content.html || '', content.plainText || markdown],
|
||||
world: 'MAIN',
|
||||
})
|
||||
const fillResult = result?.[0]?.result
|
||||
if (!fillResult?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: fillResult?.error || '腾讯云开发者社区内容填充失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已填充腾讯云开发者社区文章,请检查后保存草稿或发布',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fillTencentCloudContent,
|
||||
inspectTencentCloudTask,
|
||||
inspectTencentCloudTaskState,
|
||||
parseTencentCloudPublishedUrl,
|
||||
syncTencentCloudContent,
|
||||
TencentCloudPlatform,
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
const ToutiaoPlatform = {
|
||||
id: 'toutiao',
|
||||
name: 'Toutiao',
|
||||
icon: 'https://sf3-cdn-tos.toutiaostatic.com/obj/eden-cn/uhbfnupkbps/toutiao_favicon.ico',
|
||||
url: 'https://mp.toutiao.com',
|
||||
publishUrl: 'https://mp.toutiao.com/profile_v4/graphic/publish',
|
||||
title: '今日头条',
|
||||
type: 'toutiao',
|
||||
}
|
||||
|
||||
function parseToutiaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://mp.toutiao.com')
|
||||
if (url.protocol !== 'https:' || !['toutiao.com', 'www.toutiao.com'].includes(url.hostname)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const published = url.pathname.match(/^\/(?:article|item|w)\/([^/?#]+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认今日头条文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getToutiaoDraftId(url) {
|
||||
for (const key of ['draft_id', 'draftId']) {
|
||||
const value = url.searchParams.get(key)
|
||||
if (value?.trim()) return value.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inspectToutiaoTaskState(rawUrl, publicUrl) {
|
||||
const published = parseToutiaoPublishedUrl(publicUrl) || parseToutiaoPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取今日头条标签页地址' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'mp.toutiao.com') {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开今日头条,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/profile_v4\/graphic\/publish\/?$/.test(url.pathname)) {
|
||||
const draftId = getToutiaoDraftId(url)
|
||||
if (draftId) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draftId,
|
||||
message: '已确认今日头条草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/profile_v4\/graphic\/articles\/?$/.test(url.pathname)) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '已回到今日头条文章列表,请打开本次文章核对发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前今日头条页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function renderToutiaoInline(value) {
|
||||
const pattern = /!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g
|
||||
let html = ''
|
||||
let cursor = 0
|
||||
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
html += escapeHtml(value.slice(cursor, match.index))
|
||||
if (match[1]) {
|
||||
const url = safeUrl(match[2])
|
||||
html += url
|
||||
? `<img src="${escapeHtml(url)}" alt="${escapeHtml(match[1])}" />`
|
||||
: escapeHtml(match[0])
|
||||
} else if (match[3]) {
|
||||
const url = safeUrl(match[4])
|
||||
html += url
|
||||
? `<a href="${escapeHtml(url)}">${escapeHtml(match[3])}</a>`
|
||||
: escapeHtml(match[0])
|
||||
} else if (match[5]) {
|
||||
html += `<code>${escapeHtml(match[5])}</code>`
|
||||
} else if (match[6] || match[7]) {
|
||||
html += `<strong>${escapeHtml(match[6] || match[7])}</strong>`
|
||||
} else {
|
||||
html += `<em>${escapeHtml(match[8] || match[9])}</em>`
|
||||
}
|
||||
cursor = match.index + match[0].length
|
||||
}
|
||||
|
||||
return html + escapeHtml(value.slice(cursor))
|
||||
}
|
||||
|
||||
function renderToutiaoMarkdown(markdown) {
|
||||
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const blocks = []
|
||||
let paragraph = []
|
||||
let listType
|
||||
let listItems = []
|
||||
let codeLines
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length) {
|
||||
blocks.push(`<p>${paragraph.map(renderToutiaoInline).join('<br />')}</p>`)
|
||||
paragraph = []
|
||||
}
|
||||
}
|
||||
|
||||
const flushList = () => {
|
||||
if (!listType) return
|
||||
blocks.push(`<${listType}>${listItems.map(item => `<li>${renderToutiaoInline(item)}</li>`).join('')}</${listType}>`)
|
||||
listType = undefined
|
||||
listItems = []
|
||||
}
|
||||
|
||||
const flushBlocks = () => {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (codeLines) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
codeLines = undefined
|
||||
} else {
|
||||
codeLines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*```/.test(line)) {
|
||||
flushBlocks()
|
||||
codeLines = []
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushBlocks()
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
|
||||
if (heading) {
|
||||
flushBlocks()
|
||||
const level = heading[1].length
|
||||
blocks.push(`<h${level}>${renderToutiaoInline(heading[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const unordered = line.match(/^\s*[-*+]\s+(.+)$/)
|
||||
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/)
|
||||
if (unordered || ordered) {
|
||||
flushParagraph()
|
||||
const nextType = unordered ? 'ul' : 'ol'
|
||||
if (listType && listType !== nextType) flushList()
|
||||
listType = nextType
|
||||
listItems.push((unordered || ordered)[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const quote = line.match(/^\s*>\s?(.*)$/)
|
||||
if (quote) {
|
||||
flushBlocks()
|
||||
blocks.push(`<blockquote>${renderToutiaoInline(quote[1])}</blockquote>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
|
||||
flushBlocks()
|
||||
blocks.push('<hr />')
|
||||
continue
|
||||
}
|
||||
|
||||
if (listType) flushList()
|
||||
paragraph.push(line)
|
||||
}
|
||||
|
||||
if (codeLines) blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
flushBlocks()
|
||||
return blocks.join('')
|
||||
}
|
||||
|
||||
function renderToutiaoPlainText(markdown) {
|
||||
return String(markdown || '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[`*_#>~-]/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function fillToutiaoContent(title, html, plainText) {
|
||||
const contentHtml = html || ''
|
||||
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
input.focus()
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function selectEditorContents(editor) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
function dispatchHtmlPaste(editor, html, plainText) {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
if (typeof DataTransfer !== 'undefined' && typeof ClipboardEvent !== 'undefined') {
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', plainText)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'.editor-title textarea',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到今日头条标题输入框' }
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '今日头条未确认接收标题' }
|
||||
}
|
||||
}
|
||||
if (!contentHtml) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'.ProseMirror',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
], titleInput)
|
||||
if (!editor) return { success: false, error: '未找到今日头条正文编辑器' }
|
||||
|
||||
dispatchHtmlPaste(editor, contentHtml, contentText)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: contentText }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
const expectedText = htmlToComparableText(contentHtml)
|
||||
const expectedImageCount = Array.from(contentHtml.matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
let editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
let imageCount = editor.querySelectorAll('img').length
|
||||
const hasExpectedContent = () => (
|
||||
(expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
))
|
||||
&& (expectedImageCount === 0 || imageCount >= expectedImageCount)
|
||||
)
|
||||
|
||||
if (!hasExpectedContent() && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, contentHtml)
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: contentText }))
|
||||
await sleep(500)
|
||||
editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
imageCount = editor.querySelectorAll('img').length
|
||||
}
|
||||
|
||||
if (!hasExpectedContent()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '今日头条未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'paste-html',
|
||||
imageCount,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function syncToutiaoContent(tab, content, helpers) {
|
||||
if (!tab?.id) return { success: false, message: '无法获取今日头条编辑器标签页' }
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const markdown = content.markdown || content.body || ''
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillToutiaoContent,
|
||||
args: [
|
||||
content.title,
|
||||
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 {
|
||||
ToutiaoPlatform,
|
||||
fillToutiaoContent,
|
||||
inspectToutiaoTask,
|
||||
inspectToutiaoTaskState,
|
||||
parseToutiaoPublishedUrl,
|
||||
renderToutiaoMarkdown,
|
||||
renderToutiaoPlainText,
|
||||
syncToutiaoContent,
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Twitter Articles 平台配置
|
||||
// 使用 marked 进行 Markdown 解析,并转换为 Twitter Articles 支持的格式
|
||||
|
||||
const TwitterPlatform = {
|
||||
id: 'twitter',
|
||||
name: 'Twitter',
|
||||
icon: 'https://abs.twimg.com/favicons/twitter.3.ico',
|
||||
url: 'https://x.com',
|
||||
publishUrl: 'https://x.com/compose/articles/edit/',
|
||||
title: 'Twitter Articles',
|
||||
type: 'twitter',
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 Markdown 渲染器
|
||||
* 将 Markdown 转换为 Twitter Articles 支持的 HTML 格式
|
||||
*/
|
||||
function createTwitterRenderer(marked) {
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
// 标题转换 - Twitter Articles 支持 h1, h2, h3
|
||||
renderer.heading = function (text, level) {
|
||||
// Twitter Articles 使用 h1 作为标题,h2 作为副标题
|
||||
// 文章内容中的标题映射:# -> h2, ## -> h3, ### -> h4
|
||||
const mappedLevel = Math.min(level + 1, 4)
|
||||
return `<h${mappedLevel}>${text}</h${mappedLevel}>\n`
|
||||
}
|
||||
|
||||
// 段落
|
||||
renderer.paragraph = function (text) {
|
||||
return `<p>${text}</p>\n`
|
||||
}
|
||||
|
||||
// 粗体
|
||||
renderer.strong = function (text) {
|
||||
return `<strong>${text}</strong>`
|
||||
}
|
||||
|
||||
// 斜体
|
||||
renderer.em = function (text) {
|
||||
return `<em>${text}</em>`
|
||||
}
|
||||
|
||||
// 删除线
|
||||
renderer.del = function (text) {
|
||||
return `<s>${text}</s>`
|
||||
}
|
||||
|
||||
// 代码块 - Twitter Articles 不原生支持代码高亮,使用 pre + code 格式
|
||||
renderer.code = function (code, language) {
|
||||
// 转义 HTML 特殊字符
|
||||
const escapedCode = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
// 使用带样式的 pre 标签,模拟代码块效果
|
||||
return `<pre style="background-color: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 14px; line-height: 1.45;"><code>${escapedCode}</code></pre>\n`
|
||||
}
|
||||
|
||||
// 行内代码
|
||||
renderer.codespan = function (code) {
|
||||
const escapedCode = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<code style="background-color: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-family: 'SF Mono', Consolas, monospace; font-size: 0.9em;">${escapedCode}</code>`
|
||||
}
|
||||
|
||||
// 引用块
|
||||
renderer.blockquote = function (quote) {
|
||||
return `<blockquote style="border-left: 4px solid #1d9bf0; padding-left: 16px; margin: 16px 0; color: #536471;">${quote}</blockquote>\n`
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
renderer.list = function (body, ordered) {
|
||||
const tag = ordered ? 'ol' : 'ul'
|
||||
return `<${tag}>${body}</${tag}>\n`
|
||||
}
|
||||
|
||||
// 列表项
|
||||
renderer.listitem = function (text) {
|
||||
return `<li>${text}</li>\n`
|
||||
}
|
||||
|
||||
// 链接
|
||||
renderer.link = function (href, title, text) {
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
}
|
||||
|
||||
// 图片
|
||||
renderer.image = function (href, title, text) {
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
const altAttr = text ? ` alt="${text}"` : ''
|
||||
return `<img src="${href}"${altAttr}${titleAttr} style="max-width: 100%; height: auto;" />`
|
||||
}
|
||||
|
||||
// 水平分割线
|
||||
renderer.hr = function () {
|
||||
return `<hr style="border: none; border-top: 1px solid #cfd9de; margin: 24px 0;" />\n`
|
||||
}
|
||||
|
||||
// 表格
|
||||
renderer.table = function (header, body) {
|
||||
return `<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
|
||||
<thead>${header}</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>\n`
|
||||
}
|
||||
|
||||
renderer.tablerow = function (content) {
|
||||
return `<tr>${content}</tr>\n`
|
||||
}
|
||||
|
||||
renderer.tablecell = function (content, flags) {
|
||||
const tag = flags.header ? 'th' : 'td'
|
||||
const align = flags.align
|
||||
? ` style="text-align: ${flags.align}; border: 1px solid #cfd9de; padding: 8px;"`
|
||||
: ' style="border: 1px solid #cfd9de; padding: 8px;"'
|
||||
return `<${tag}${align}>${content}</${tag}>\n`
|
||||
}
|
||||
|
||||
return renderer
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理数学公式
|
||||
* 将 LaTeX 公式转换为可显示的格式
|
||||
* Twitter Articles 不原生支持 LaTeX,这里转换为图片或文本格式
|
||||
*/
|
||||
function processLatexFormulas(markdown) {
|
||||
// 处理行内公式 $...$
|
||||
let processed = markdown.replace(/\$([^\$\n]+)\$/g, (match, formula) => {
|
||||
// 使用 CodeCogs API 将 LaTeX 转换为图片
|
||||
const encodedFormula = encodeURIComponent(formula.trim())
|
||||
return `<img src="https://latex.codecogs.com/svg.image?${encodedFormula}" alt="${formula}" style="vertical-align: middle;" />`
|
||||
})
|
||||
|
||||
// 处理块级公式 $$...$$
|
||||
processed = processed.replace(/\$\$([^\$]+)\$\$/g, (match, formula) => {
|
||||
const encodedFormula = encodeURIComponent(formula.trim())
|
||||
return `<div style="text-align: center; margin: 16px 0;"><img src="https://latex.codecogs.com/svg.image?${encodedFormula}" alt="${formula}" /></div>`
|
||||
})
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 转换为 Twitter Articles 支持的 HTML
|
||||
* @param {string} markdown - 原始 Markdown 内容
|
||||
* @param {object} marked - marked 库实例
|
||||
* @returns {string} - 转换后的 HTML
|
||||
*/
|
||||
function convertMarkdownToTwitterHtml(markdown, marked) {
|
||||
if (!markdown) return ''
|
||||
|
||||
// 先处理 LaTeX 公式
|
||||
let processedMarkdown = processLatexFormulas(markdown)
|
||||
|
||||
// 配置 marked
|
||||
const renderer = createTwitterRenderer(marked)
|
||||
marked.setOptions({
|
||||
renderer: renderer,
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
pedantic: false,
|
||||
})
|
||||
|
||||
// 转换 Markdown 为 HTML
|
||||
const html = marked.parse(processedMarkdown)
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitter Articles 内容填充函数
|
||||
* 流程:
|
||||
* 1. 等待编辑器加载
|
||||
* 2. 使用 marked 解析 Markdown 并转换格式
|
||||
* 3. 填充标题到 textarea[placeholder="Add a title"]
|
||||
* 4. 通过 paste 事件填充 HTML 内容到 Draft.js 编辑器
|
||||
*/
|
||||
async function fillTwitterContent(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
|
||||
console.log('[COSE] Twitter Articles 开始同步...')
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// 动态加载 marked 库
|
||||
let marked
|
||||
try {
|
||||
// 尝试从 CDN 加载 marked
|
||||
if (!window.marked) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js'
|
||||
document.head.appendChild(script)
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
setTimeout(reject, 5000) // 5秒超时
|
||||
})
|
||||
}
|
||||
marked = window.marked
|
||||
} catch (e) {
|
||||
console.error('[COSE] 加载 marked 库失败:', e)
|
||||
// 降级处理:直接使用原始内容
|
||||
marked = null
|
||||
}
|
||||
|
||||
// 转换 Markdown 为 Twitter Articles HTML
|
||||
let htmlContent
|
||||
if (marked && markdown) {
|
||||
htmlContent = convertMarkdownToTwitterHtml(markdown, marked)
|
||||
console.log('[COSE] Markdown 已转换为 HTML')
|
||||
} else {
|
||||
// 降级:使用原始 body 或简单转换
|
||||
htmlContent = body || markdown || ''
|
||||
if (!htmlContent.includes('<')) {
|
||||
// 如果是纯文本,简单转换为段落
|
||||
htmlContent = htmlContent
|
||||
.split('\n\n')
|
||||
.map(p => `<p>${p}</p>`)
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// 第一步:填充标题
|
||||
// Twitter Articles 使用 textarea[placeholder="Add a title"]
|
||||
const titleInput = await waitFor(
|
||||
'textarea[placeholder="Add a title"], textarea[name="Article Title"]',
|
||||
5000
|
||||
)
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 使用 native setter 来绑定 React 的受控组件
|
||||
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] Twitter Articles 标题填充成功')
|
||||
}
|
||||
|
||||
// 第二步:填充内容
|
||||
// Twitter Articles 使用 Draft.js 编辑器
|
||||
const contentEl = await waitFor(
|
||||
'.public-DraftEditor-content[contenteditable="true"], .DraftEditor-root [contenteditable="true"]',
|
||||
5000
|
||||
)
|
||||
if (contentEl && htmlContent) {
|
||||
contentEl.focus()
|
||||
|
||||
// 创建 DataTransfer 并设置 HTML 内容
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/html', htmlContent)
|
||||
dt.setData('text/plain', htmlContent.replace(/<[^>]*>/g, ''))
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
contentEl.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] Twitter Articles 内容填充成功 (Draft.js)')
|
||||
} else {
|
||||
console.log('[COSE] Twitter Articles 未找到内容编辑器')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { TwitterPlatform, fillTwitterContent, convertMarkdownToTwitterHtml }
|
||||
@@ -0,0 +1,13 @@
|
||||
// 火山引擎开发者社区平台配置
|
||||
const VolcenginePlatform = {
|
||||
id: 'volcengine',
|
||||
name: 'Volcengine',
|
||||
icon: 'https://lf1-cdn-tos.bytegoofy.com/goofy/tech-fe/fav.png',
|
||||
url: 'https://developer.volcengine.com/',
|
||||
publishUrl: 'https://developer.volcengine.com/articles/draft',
|
||||
title: '火山引擎开发者社区',
|
||||
type: 'volcengine',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { VolcenginePlatform }
|
||||
@@ -0,0 +1,343 @@
|
||||
import { renderToutiaoMarkdown, renderToutiaoPlainText } from './toutiao.js'
|
||||
|
||||
const WangyihaoPlatform = {
|
||||
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: 'wangyi',
|
||||
}
|
||||
|
||||
function parseWangyihaoPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://mp.163.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'www.163.com') return null
|
||||
|
||||
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: '已确认网易号编辑器显示草稿保存成功',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到网易号编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前网易号页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillWangyihaoContent(title, html, plainText) {
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function htmlToComparableText(value) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/<img\b[^>]*>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
|
||||
}
|
||||
|
||||
const expectedText = htmlToComparableText(html)
|
||||
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
|
||||
function findFirst(selectors, exclude) {
|
||||
return selectors
|
||||
.flatMap(selector => Array.from(document.querySelectorAll(selector)))
|
||||
.find(element => element !== exclude)
|
||||
}
|
||||
|
||||
function waitForElement(selectors, exclude, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, exclude)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
if (setter) setter.call(input, value)
|
||||
else input.value = value
|
||||
}
|
||||
|
||||
input.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: value,
|
||||
inputType: 'insertText',
|
||||
}))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
|
||||
function dispatchHtmlPaste(editor, nextHtml, nextPlainText) {
|
||||
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
|
||||
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/html', nextHtml)
|
||||
clipboardData.setData('text/plain', nextPlainText)
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
function hasExpectedContent(editor) {
|
||||
const editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
const imageCount = editor.querySelectorAll('img').length
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
|
||||
return receivedText && receivedImages
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'textarea.netease-textarea',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'[contenteditable="true"][placeholder*="标题"]',
|
||||
])
|
||||
if (!titleInput) return { success: false, error: '未找到网易号标题输入框' }
|
||||
if (title) {
|
||||
setTitleValue(titleInput, title)
|
||||
await sleep(200)
|
||||
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
|
||||
if (normalizeText(receivedTitle) !== normalizeText(title)) {
|
||||
return { success: false, error: '网易号未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return { success: true, method: 'title-only' }
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.public-DraftEditor-content[contenteditable="true"]',
|
||||
'.public-DraftEditor-content',
|
||||
'.DraftEditor-root [contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'[contenteditable="true"][data-contents="true"]',
|
||||
], titleInput)
|
||||
if (!editor) return { success: false, error: '未找到网易号正文编辑器' }
|
||||
|
||||
dispatchHtmlPaste(editor, html, plainText)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: plainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(700)
|
||||
|
||||
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
|
||||
editor.focus()
|
||||
selectEditorContents(editor)
|
||||
document.execCommand('insertHTML', false, html)
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: plainText,
|
||||
inputType: 'insertFromPaste',
|
||||
}))
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
if (!hasExpectedContent(editor)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '网易号未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'paste-html',
|
||||
imageCount: editor.querySelectorAll('img').length,
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectWangyihaoTask(tab, helpers) {
|
||||
const currentState = inspectWangyihaoTaskState(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.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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 微信公众号平台配置
|
||||
const WechatPlatform = {
|
||||
id: 'wechat',
|
||||
name: 'WeChat',
|
||||
icon: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico',
|
||||
url: 'https://mp.weixin.qq.com',
|
||||
// 先打开草稿箱,再自动点击新建
|
||||
publishUrl:
|
||||
'https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10',
|
||||
title: '微信公众号',
|
||||
type: 'wechat',
|
||||
}
|
||||
|
||||
function getEditorArea(editor) {
|
||||
return (editor?.clientHeight || 0) * (editor?.clientWidth || 0)
|
||||
}
|
||||
|
||||
function isWechatTitleEditor(editor, titleEditor) {
|
||||
return (
|
||||
Boolean(editor) && (editor === titleEditor || Boolean(editor.closest?.('.title-editor__input')))
|
||||
)
|
||||
}
|
||||
|
||||
function pickWechatBodyProseMirrorCandidate(nodes, { titleInput, titleEditor } = {}) {
|
||||
const bodyCandidates = nodes.filter(editor => !isWechatTitleEditor(editor, titleEditor))
|
||||
if (bodyCandidates.length === 0) return null
|
||||
if (bodyCandidates.length === 1) return bodyCandidates[0]
|
||||
|
||||
const byPlaceholder = bodyCandidates.find(editor =>
|
||||
(editor.textContent || '').includes('从这里开始写正文')
|
||||
)
|
||||
if (byPlaceholder) return byPlaceholder
|
||||
|
||||
if (titleInput) {
|
||||
const band = titleInput.getBoundingClientRect()
|
||||
const belowTitle = bodyCandidates.filter(editor => {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
return rect.top >= band.bottom - 8
|
||||
})
|
||||
if (belowTitle.length > 0) {
|
||||
return belowTitle.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
}
|
||||
|
||||
return bodyCandidates.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
|
||||
// 微信公众号内容填充函数(在页面主世界中执行)
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
async function fillWechatContent(title, htmlBody) {
|
||||
/**
|
||||
* 后台改版后可能存在多个 `.ProseMirror`(标题区也可能是 ProseMirror),
|
||||
* `querySelector('.ProseMirror')` 常会命中标题编辑器,导致正文 HTML 被贴进标题。
|
||||
* 另外,正文编辑器有时会比标题编辑器晚挂载,这时也要继续等待,不能把唯一节点误判成正文。
|
||||
*/
|
||||
function pickWechatBodyProseMirror() {
|
||||
// 内联辅助函数,确保 chrome.scripting.executeScript 注入时可用
|
||||
function getEditorArea(editor) {
|
||||
return (editor?.clientHeight || 0) * (editor?.clientWidth || 0)
|
||||
}
|
||||
function isWechatTitleEditor(editor, titleEditor) {
|
||||
return (
|
||||
Boolean(editor) &&
|
||||
(editor === titleEditor || Boolean(editor.closest?.('.title-editor__input')))
|
||||
)
|
||||
}
|
||||
function pickCandidate(nodes, { titleInput, titleEditor } = {}) {
|
||||
const bodyCandidates = nodes.filter(editor => !isWechatTitleEditor(editor, titleEditor))
|
||||
if (bodyCandidates.length === 0) return null
|
||||
if (bodyCandidates.length === 1) return bodyCandidates[0]
|
||||
|
||||
const byPlaceholder = bodyCandidates.find(editor =>
|
||||
(editor.textContent || '').includes('从这里开始写正文')
|
||||
)
|
||||
if (byPlaceholder) return byPlaceholder
|
||||
|
||||
if (titleInput) {
|
||||
const band = titleInput.getBoundingClientRect()
|
||||
const belowTitle = bodyCandidates.filter(editor => {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
return rect.top >= band.bottom - 8
|
||||
})
|
||||
if (belowTitle.length > 0) {
|
||||
return belowTitle.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
}
|
||||
|
||||
return bodyCandidates.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
|
||||
const nodes = [...document.querySelectorAll('.ProseMirror')]
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const titleInput = document.querySelector('#title')
|
||||
const titleEditor = document.querySelector('.title-editor__input .ProseMirror')
|
||||
return pickCandidate(nodes, { titleInput, titleEditor })
|
||||
}
|
||||
|
||||
async function waitForBodyEditor(timeout = 15000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const el = pickWechatBodyProseMirror()
|
||||
if (el) return el
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
return pickWechatBodyProseMirror()
|
||||
}
|
||||
|
||||
try {
|
||||
const titleInput = await window.waitFor('#title', 15000)
|
||||
const titleEditor = await window.waitFor('.title-editor__input .ProseMirror', 15000)
|
||||
|
||||
// 填充标题(优先于正文,避免焦点停留在标题区的 ProseMirror)
|
||||
if ((titleInput || titleEditor) && title) {
|
||||
if (titleEditor) {
|
||||
titleEditor.focus()
|
||||
titleEditor.innerHTML = ''
|
||||
titleEditor.textContent = title
|
||||
titleEditor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleEditor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
}
|
||||
const nativeSetter =
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set ||
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (titleInput && nativeSetter) {
|
||||
nativeSetter.call(titleInput, title)
|
||||
} else if (titleInput) {
|
||||
titleInput.value = title
|
||||
}
|
||||
if (titleInput) {
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
console.log('[COSE] 微信标题已填充:', title)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
const editor = await waitForBodyEditor(15000)
|
||||
if (!editor) {
|
||||
return { success: false, error: '未找到正文编辑器' }
|
||||
}
|
||||
|
||||
// 填充正文内容
|
||||
if (editor && htmlBody) {
|
||||
editor.focus()
|
||||
|
||||
// 清空现有占位符内容
|
||||
if (editor.textContent.includes('从这里开始写正文')) {
|
||||
editor.innerHTML = ''
|
||||
}
|
||||
|
||||
const plainText = htmlBody.replace(/<[^>]*>/g, '')
|
||||
const hasImageInSource = /<img\b/i.test(htmlBody)
|
||||
let injected = false
|
||||
let injectError = ''
|
||||
|
||||
// 优先使用微信编辑器 JSAPI。合成 Ctrl+V 事件不会触发真实粘贴;
|
||||
// 直接写 innerHTML 也不会可靠同步到 ProseMirror 的文档模型。
|
||||
if (window.__MP_Editor_JSAPI__ && typeof window.__MP_Editor_JSAPI__.invoke === 'function') {
|
||||
injected = await new Promise(resolve => {
|
||||
let done = false
|
||||
const finish = (ok, err) => {
|
||||
if (done) return
|
||||
done = true
|
||||
if (err) injectError = err.message || String(err)
|
||||
resolve(ok)
|
||||
}
|
||||
|
||||
try {
|
||||
window.__MP_Editor_JSAPI__.invoke({
|
||||
apiName: 'mp_editor_set_content',
|
||||
apiParam: { content: htmlBody },
|
||||
sucCb: () => finish(true),
|
||||
errCb: err => finish(false, err),
|
||||
})
|
||||
} catch (err) {
|
||||
finish(false, err)
|
||||
}
|
||||
|
||||
setTimeout(() => finish(false, new Error('mp_editor_set_content 调用超时')), 5000)
|
||||
})
|
||||
|
||||
if (injected) {
|
||||
console.log('[COSE] 微信内容已通过 mp_editor_set_content 注入')
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
} else {
|
||||
console.warn('[COSE] mp_editor_set_content 注入失败:', injectError)
|
||||
}
|
||||
}
|
||||
|
||||
if (!injected) {
|
||||
if (hasImageInSource) {
|
||||
console.warn('[COSE] 正文包含图片,但微信 JSAPI 不可用;paste 兜底可能无法保留图片')
|
||||
}
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/html', htmlBody)
|
||||
dt.setData('text/plain', plainText)
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
editor.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] 微信内容已通过 paste 事件注入(兜底方案)')
|
||||
|
||||
// 等待内容渲染
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
}
|
||||
|
||||
// 验证内容是否注入成功
|
||||
const wordCount = editor.textContent?.trim().length || 0
|
||||
const imageCount = editor.querySelectorAll?.('img').length || 0
|
||||
const hasEditorContent = wordCount > 0 || imageCount > 0 || injected
|
||||
|
||||
return {
|
||||
success: hasEditorContent,
|
||||
error: hasEditorContent ? undefined : injectError || '正文注入后未检测到有效内容',
|
||||
wordCount,
|
||||
imageCount,
|
||||
titleFilled: titleInput?.value === title || titleEditor?.textContent?.trim() === title,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: '内容为空' }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 微信公众号保存草稿函数(在页面主世界中执行)
|
||||
function saveWechatDraft() {
|
||||
const saveDraftBtn = Array.from(document.querySelectorAll('button')).find(b =>
|
||||
b.textContent.includes('保存为草稿')
|
||||
)
|
||||
if (saveDraftBtn) {
|
||||
saveDraftBtn.click()
|
||||
console.log('[COSE] 已点击保存为草稿')
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: '未找到保存按钮' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信公众号同步处理器
|
||||
* @param {object} tab - Chrome tab 对象(初始为首页)
|
||||
* @param {object} content - 内容对象 { title, body, markdown, wechatHtml }
|
||||
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup, PLATFORMS }
|
||||
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
|
||||
*/
|
||||
async function syncWechatContent(tab, content, helpers) {
|
||||
const { chrome, waitForTab } = helpers
|
||||
|
||||
// 步骤1:等待首页加载完成
|
||||
console.log('[COSE] 微信公众号等待页面加载')
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 注入公共工具函数(waitFor, setInputValue)
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 步骤2:使用 MutationObserver 监听获取 token
|
||||
console.log('[COSE] 开始检测 token...')
|
||||
const [tokenResult] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
return new Promise(resolve => {
|
||||
// 先检查当前页面是否已有 token
|
||||
const checkToken = () => {
|
||||
const urlMatch = window.location.href.match(/token=(\d+)/)
|
||||
if (urlMatch) return urlMatch[1]
|
||||
|
||||
const links = document.querySelectorAll('a[href*="token"]')
|
||||
for (const link of links) {
|
||||
const match = link.href?.match(/token=(\d+)/)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
const scripts = document.querySelectorAll('script:not([src])')
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent
|
||||
const match = content.match(/token["']?\s*[:=]\s*["']?(\d+)["']?/i)
|
||||
if (match && match[1]) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = checkToken()
|
||||
if (existing) return resolve(existing)
|
||||
|
||||
// 使用 MutationObserver 监听 DOM 变化
|
||||
const observer = new MutationObserver(() => {
|
||||
const token = checkToken()
|
||||
if (token) {
|
||||
observer.disconnect()
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
|
||||
// 超时保护
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(checkToken())
|
||||
}, 10000)
|
||||
})
|
||||
},
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
const token = tokenResult?.result
|
||||
|
||||
if (!token) {
|
||||
console.error('[COSE] 无法从页面获取 token')
|
||||
return { success: false, message: '无法获取微信公众号 token,请确保已登录', tabId: tab.id }
|
||||
}
|
||||
|
||||
// 步骤3:跳转到编辑器页面
|
||||
const editorUrl = `https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10&token=${token}&lang=zh_CN`
|
||||
console.log('[COSE] 获取到 token:', token, '跳转到编辑器')
|
||||
|
||||
await chrome.tabs.update(tab.id, { url: editorUrl })
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 使用剪贴板 HTML(带完整样式)或降级到 body
|
||||
const htmlContent = content.wechatHtml || content.html || content.body || ''
|
||||
console.log('[COSE] 微信 HTML 内容长度:', htmlContent?.length || 0)
|
||||
|
||||
// 步骤4:使用 MutationObserver 监听编辑器出现
|
||||
console.log('[COSE] 正在等待编辑器...')
|
||||
const [editorResult] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
return new Promise(resolve => {
|
||||
const existing = document.querySelector('.ProseMirror')
|
||||
if (existing) return resolve(true)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelector('.ProseMirror')) {
|
||||
observer.disconnect()
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(!!document.querySelector('.ProseMirror'))
|
||||
}, 15000)
|
||||
})
|
||||
},
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
if (!editorResult?.result) {
|
||||
console.error('[COSE] 编辑器等待超时')
|
||||
return { success: false, message: '编辑器加载超时', tabId: tab.id }
|
||||
}
|
||||
|
||||
console.log('[COSE] 编辑器已就绪,开始注入内容...')
|
||||
|
||||
// 页面跳转后需要重新注入工具函数(waitFor, setInputValue)
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 步骤5:填充内容
|
||||
let result
|
||||
try {
|
||||
result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillWechatContent,
|
||||
args: [content.title, htmlContent],
|
||||
world: 'MAIN',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[COSE] executeScript 执行失败:', e)
|
||||
return { success: false, message: '脚本执行失败: ' + e.message, tabId: tab.id }
|
||||
}
|
||||
|
||||
const fillResult = result?.[0]?.result
|
||||
console.log('[COSE] 微信填充结果:', JSON.stringify(fillResult, null, 2))
|
||||
|
||||
if (!fillResult?.success) {
|
||||
console.error('[COSE] 微信内容填充失败:', fillResult?.error)
|
||||
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
|
||||
}
|
||||
|
||||
console.log('[COSE] 微信内容填充成功,字数:', fillResult.wordCount)
|
||||
|
||||
if (content.options?.saveAsDraft === true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const saveResult = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: saveWechatDraft,
|
||||
world: 'MAIN',
|
||||
})
|
||||
if (!saveResult?.[0]?.result?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: saveResult?.[0]?.result?.error || '内容已填充,但保存微信草稿失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
return { success: true, message: '已填充并保存到微信草稿箱', tabId: tab.id }
|
||||
}
|
||||
|
||||
return { success: true, message: '已填充到微信公众号编辑器,请检查后手动保存', tabId: tab.id }
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { WechatPlatform, fillWechatContent, pickWechatBodyProseMirrorCandidate, syncWechatContent }
|
||||
@@ -0,0 +1,13 @@
|
||||
// 微博头条文章平台配置
|
||||
const WeiboPlatform = {
|
||||
id: 'weibo',
|
||||
name: 'Weibo',
|
||||
icon: 'https://weibo.com/favicon.ico',
|
||||
url: 'https://weibo.com',
|
||||
publishUrl: 'https://card.weibo.com/article/v5/editor#/draft',
|
||||
title: '微博头条',
|
||||
type: 'weibo',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { WeiboPlatform }
|
||||
@@ -0,0 +1,20 @@
|
||||
// 小红书平台配置
|
||||
// 同步方式:使用剪贴板 HTML 粘贴到编辑器
|
||||
const XiaohongshuPlatform = {
|
||||
id: 'xiaohongshu',
|
||||
name: 'Xiaohongshu',
|
||||
icon: 'https://www.xiaohongshu.com/favicon.ico',
|
||||
url: 'https://creator.xiaohongshu.com',
|
||||
publishUrl: 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=article',
|
||||
title: '小红书',
|
||||
type: 'xiaohongshu',
|
||||
}
|
||||
|
||||
// 小红书内容填充函数(由 background.js 处理)
|
||||
// 使用剪贴板粘贴方式填充内容
|
||||
async function fillXiaohongshuContent(content, waitFor, setInputValue) {
|
||||
console.log('[COSE] 小红书填充由 background.js 处理')
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { XiaohongshuPlatform, fillXiaohongshuContent }
|
||||
@@ -0,0 +1,326 @@
|
||||
const ZhihuPlatform = {
|
||||
id: 'zhihu',
|
||||
name: 'Zhihu',
|
||||
icon: 'https://static.zhihu.com/heifetz/favicon.ico',
|
||||
url: 'https://www.zhihu.com',
|
||||
publishUrl: 'https://zhuanlan.zhihu.com/write',
|
||||
title: '知乎',
|
||||
type: 'zhihu',
|
||||
}
|
||||
|
||||
function parseZhihuPublishedUrl(rawUrl) {
|
||||
if (!rawUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, 'https://zhuanlan.zhihu.com')
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'zhuanlan.zhihu.com') return null
|
||||
|
||||
const published = url.pathname.match(/^\/p\/([^/?#]+)\/?$/)
|
||||
if (!published?.[1]) return null
|
||||
|
||||
return {
|
||||
status: 'published',
|
||||
url: url.href,
|
||||
platformWorkId: published[1],
|
||||
message: '已确认知乎文章公开地址',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inspectZhihuTaskState(rawUrl, publicUrl) {
|
||||
const published = parseZhihuPublishedUrl(publicUrl) || parseZhihuPublishedUrl(rawUrl)
|
||||
if (published) return published
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
return { status: 'failed', message: '无法读取知乎标签页地址' }
|
||||
}
|
||||
|
||||
if (
|
||||
url.protocol !== 'https:'
|
||||
|| !['zhuanlan.zhihu.com', 'www.zhihu.com'].includes(url.hostname)
|
||||
) {
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
message: '当前标签页已经离开知乎,无法确认发布结果',
|
||||
}
|
||||
}
|
||||
|
||||
if (url.hostname === 'zhuanlan.zhihu.com') {
|
||||
const draft = url.pathname.match(/^\/write\/([^/?#]+)\/?$/)
|
||||
if (draft?.[1]) {
|
||||
return {
|
||||
status: 'draft_saved',
|
||||
url: url.href,
|
||||
platformWorkId: draft[1],
|
||||
message: '已确认知乎草稿地址',
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\/write\/?$/.test(url.pathname)) {
|
||||
return {
|
||||
status: 'filled',
|
||||
url: url.href,
|
||||
message: '内容已填充到知乎新建编辑器,请检查后保存草稿或发布',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'publication_uncertain',
|
||||
url: url.href,
|
||||
message: '当前知乎页面无法确认是草稿还是已发布文章',
|
||||
}
|
||||
}
|
||||
|
||||
function fillZhihuContent(title, markdown, body) {
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
const normalizeText = value => String(value || '').replace(/\s+/g, '')
|
||||
const normalizeComparableText = value => String(value || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
function markdownToComparableText(value) {
|
||||
return normalizeComparableText(String(value || '')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||
.replace(/<[^>]+>/g, ''))
|
||||
}
|
||||
|
||||
function findFirst(selectors, excludedElement) {
|
||||
for (const selector of selectors) {
|
||||
const element = Array.from(document.querySelectorAll(selector))
|
||||
.find(candidate => (
|
||||
candidate !== excludedElement
|
||||
&& !candidate.disabled
|
||||
&& candidate.offsetParent !== null
|
||||
))
|
||||
if (element) return element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function waitForElement(selectors, excludedElement, timeout = 15000) {
|
||||
return new Promise(resolve => {
|
||||
const existing = findFirst(selectors, excludedElement)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = findFirst(selectors, excludedElement)
|
||||
if (!element) return
|
||||
|
||||
observer.disconnect()
|
||||
resolve(element)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
window.setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(findFirst(selectors, excludedElement))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? 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 sleep(150)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fill() {
|
||||
const titleInput = await waitForElement([
|
||||
'textarea[placeholder*="标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'.WriteIndex-titleInput textarea',
|
||||
'.WriteIndex-titleInput input',
|
||||
])
|
||||
if (!titleInput) {
|
||||
return { success: false, error: '未找到知乎标题输入框' }
|
||||
}
|
||||
if (title) {
|
||||
setInputValue(titleInput, title)
|
||||
await sleep(200)
|
||||
if (normalizeText(titleInput.value) !== normalizeText(title)) {
|
||||
return { success: false, error: '知乎未确认接收标题' }
|
||||
}
|
||||
}
|
||||
|
||||
const editor = await waitForElement([
|
||||
'.public-DraftEditor-content[contenteditable="true"]',
|
||||
'.public-DraftEditor-content',
|
||||
'.DraftEditor-root [contenteditable="true"]',
|
||||
'.WriteIndex-content [contenteditable="true"]',
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
], titleInput)
|
||||
if (!editor) {
|
||||
return { success: false, error: '未找到知乎正文编辑器' }
|
||||
}
|
||||
if (!contentToFill) {
|
||||
return { success: true, method: 'title-only' }
|
||||
}
|
||||
|
||||
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') {
|
||||
return { success: false, error: '当前浏览器无法向知乎编辑器粘贴 Markdown' }
|
||||
}
|
||||
|
||||
editor.focus()
|
||||
clearEditor(editor)
|
||||
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.setData('text/plain', contentToFill)
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}))
|
||||
|
||||
const parsed = await clickMarkdownParser()
|
||||
await sleep(parsed ? 1000 : 600)
|
||||
|
||||
const expectedText = markdownToComparableText(contentToFill)
|
||||
const editorText = normalizeComparableText(editor.innerText || editor.textContent)
|
||||
const expectedImageCount = Array.from(contentToFill.matchAll(/!\[[^\]]*]\([^)]*\)/g)).length
|
||||
const editorImageCount = editor.querySelectorAll('img').length
|
||||
const minimumTextLength = Math.floor(expectedText.length * 0.8)
|
||||
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
|
||||
const sampleStarts = expectedText.length > sampleLength
|
||||
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
|
||||
: [0]
|
||||
const expectedSamples = [...new Set(sampleStarts.map(start => (
|
||||
expectedText.slice(start, start + sampleLength)
|
||||
)).filter(Boolean))]
|
||||
const receivedText = expectedText.length === 0 || (
|
||||
editorText.length >= minimumTextLength
|
||||
&& expectedSamples.every(sample => editorText.includes(sample))
|
||||
)
|
||||
const receivedImages = expectedImageCount === 0 || editorImageCount >= expectedImageCount
|
||||
if (!receivedText || !receivedImages) {
|
||||
return {
|
||||
success: false,
|
||||
error: '知乎未确认接收正文,请在编辑器中手动粘贴后再重试',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: parsed ? 'markdown-parse' : 'paste',
|
||||
}
|
||||
}
|
||||
|
||||
return fill()
|
||||
}
|
||||
|
||||
async function inspectZhihuTask(tab, helpers) {
|
||||
const currentState = inspectZhihuTaskState(tab?.url || '')
|
||||
if (!tab?.id || currentState.status === 'published') return currentState
|
||||
|
||||
try {
|
||||
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: '无法获取知乎编辑器标签页' }
|
||||
}
|
||||
|
||||
await helpers.waitForTab(tab.id)
|
||||
const result = await helpers.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillZhihuContent,
|
||||
args: [content.title, content.markdown, 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 {
|
||||
ZhihuPlatform,
|
||||
fillZhihuContent,
|
||||
inspectZhihuTask,
|
||||
inspectZhihuTaskState,
|
||||
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,70 @@
|
||||
// 通用平台工具函数
|
||||
|
||||
/**
|
||||
* 注入通用工具函数到页面主世界
|
||||
* 此函数会在页面中定义 window.waitFor 和 window.setInputValue
|
||||
*/
|
||||
function injectCommonUtils() {
|
||||
// 等待元素出现的工具函数(使用 MutationObserver)
|
||||
window.waitFor = (selector, timeout = 10000) => {
|
||||
return new Promise(resolve => {
|
||||
const el = document.querySelector(selector)
|
||||
if (el) return resolve(el)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = document.querySelector(selector)
|
||||
if (el) {
|
||||
observer.disconnect()
|
||||
resolve(el)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(document.querySelector(selector))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// 设置输入值的工具函数
|
||||
window.setInputValue = (el, value) => {
|
||||
if (!el || !value) return
|
||||
el.focus()
|
||||
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
||||
// 使用 native setter 确保 React/Vue 等框架能检测到变化
|
||||
const nativeSetter =
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set ||
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(el, value)
|
||||
} else {
|
||||
el.value = value
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
} else if (el.contentEditable === 'true') {
|
||||
el.innerHTML = value.replace(/\n/g, '<br>')
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 在页面中注入通用工具函数
|
||||
* @param {object} chrome - Chrome API 对象
|
||||
* @param {number} tabId - 目标 tab ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function injectUtils(chrome, tabId) {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: injectCommonUtils,
|
||||
world: 'MAIN',
|
||||
})
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { injectCommonUtils, injectUtils }
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './src/configs.js'
|
||||
export * from './src/detect.js'
|
||||
export * from './src/utils.js'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @cose/detection - Platform login detection module
|
||||
*
|
||||
* This package provides login detection configurations for all supported platforms.
|
||||
* Each config includes:
|
||||
* - api: The API endpoint to check login status
|
||||
* - method: HTTP method (GET/POST)
|
||||
* - checkLogin: Function to determine if user is logged in from response
|
||||
* - getUserInfo: Function to extract username and avatar from response
|
||||
*/
|
||||
|
||||
// 掘金
|
||||
export const JuejinLoginConfig = {
|
||||
api: 'https://api.juejin.cn/user_api/v1/user/get',
|
||||
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,
|
||||
}),
|
||||
}
|
||||
|
||||
// 知乎
|
||||
export const ZhihuLoginConfig = {
|
||||
api: 'https://www.zhihu.com/api/v4/me',
|
||||
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 && 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 && Boolean(parseBaijiahaoAccount(response).platformUid),
|
||||
getUserInfo: parseBaijiahaoAccount,
|
||||
}
|
||||
|
||||
// 抖音
|
||||
export const DouyinLoginConfig = {
|
||||
api: 'https://creator.douyin.com/web/api/media/user/info/',
|
||||
method: 'GET',
|
||||
checkLogin: response =>
|
||||
response?.status_code === 0 && (response?.user?.uid || response?.user_info?.uid),
|
||||
getUserInfo: response => ({
|
||||
username: response?.user?.nickname || response?.user_info?.nickname,
|
||||
avatar:
|
||||
response?.user?.avatar_thumb?.url_list?.[0] ||
|
||||
response?.user_info?.avatar_thumb?.url_list?.[0],
|
||||
}),
|
||||
}
|
||||
|
||||
// 统一的 LOGIN_CHECK_CONFIG 对象(按平台 ID 索引)
|
||||
export const LOGIN_CHECK_CONFIG = {
|
||||
juejin: JuejinLoginConfig,
|
||||
zhihu: ZhihuLoginConfig,
|
||||
toutiao: ToutiaoLoginConfig,
|
||||
|
||||
baijiahao: BaijiahaoLoginConfig,
|
||||
douyin: DouyinLoginConfig,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { LOGIN_CHECK_CONFIG } from './configs.js'
|
||||
import { checkLoginByCookie, detectByApi } from './utils.js'
|
||||
import { detectCSDNUser } from './platforms/csdn.js'
|
||||
import { detectOSChinaUser } from './platforms/oschina.js'
|
||||
import { detectAlipayUser } from './platforms/alipay.js'
|
||||
import { detectWeiboUser } from './platforms/weibo.js'
|
||||
import { detectWechatUser } from './platforms/wechat.js'
|
||||
import { detectXiaohongshuUser } from './platforms/xiaohongshu.js'
|
||||
import { detectElecfansUser } from './platforms/elecfans.js'
|
||||
import { detectHuaweiCloudUser } from './platforms/huaweicloud.js'
|
||||
import { detectHuaweiDevUser } from './platforms/huaweidev.js'
|
||||
import { detectSspaiUser } from './platforms/sspai.js'
|
||||
import { detectAliyunUser } from './platforms/aliyun.js'
|
||||
import { detectSohuUser } from './platforms/sohu.js'
|
||||
import { detectMediumUser } from './platforms/medium.js'
|
||||
import { detectTencentCloudUser } from './platforms/tencentcloud.js'
|
||||
import { detectQianfanUser } from './platforms/qianfan.js'
|
||||
import { detectTwitterUser } from './platforms/twitter.js'
|
||||
import { detectBilibiliUser } from './platforms/bilibili.js'
|
||||
import { detectCto51User } from './platforms/cto51.js'
|
||||
import { detectJianshuUser } from './platforms/jianshu.js'
|
||||
import { detectSegmentFaultUser } from './platforms/segmentfault.js'
|
||||
import { detectInfoQUser } from './platforms/infoq.js'
|
||||
import { detectModelScopeUser } from './platforms/modelscope.js'
|
||||
import { detectVolcengineUser } from './platforms/volcengine.js'
|
||||
import { detectCnblogsUser } from './platforms/cnblogs.js'
|
||||
import { detectWangyihaoUser } from './platforms/wangyihao.js'
|
||||
import { detectDoubanUser } from './platforms/douban.js'
|
||||
|
||||
// Platform-specific detectors map
|
||||
const PLATFORM_DETECTORS = {
|
||||
csdn: detectCSDNUser,
|
||||
oschina: detectOSChinaUser,
|
||||
alipayopen: detectAlipayUser,
|
||||
weibo: detectWeiboUser,
|
||||
wechat: detectWechatUser,
|
||||
xiaohongshu: detectXiaohongshuUser,
|
||||
elecfans: detectElecfansUser,
|
||||
huaweicloud: detectHuaweiCloudUser,
|
||||
huaweidev: detectHuaweiDevUser,
|
||||
sspai: detectSspaiUser,
|
||||
aliyun: detectAliyunUser,
|
||||
sohu: detectSohuUser,
|
||||
medium: detectMediumUser,
|
||||
tencentcloud: detectTencentCloudUser,
|
||||
qianfan: detectQianfanUser,
|
||||
twitter: detectTwitterUser,
|
||||
bilibili: detectBilibiliUser,
|
||||
'51cto': detectCto51User,
|
||||
jianshu: detectJianshuUser,
|
||||
segmentfault: detectSegmentFaultUser,
|
||||
infoq: detectInfoQUser,
|
||||
modelscope: detectModelScopeUser,
|
||||
volcengine: detectVolcengineUser,
|
||||
cnblogs: detectCnblogsUser,
|
||||
wangyi: detectWangyihaoUser,
|
||||
douban: detectDoubanUser,
|
||||
}
|
||||
|
||||
export async function detectUser(platformId) {
|
||||
console.log(`[COSE] Detection: Checking ${platformId}`)
|
||||
|
||||
// 1. Platform-specific Detectors
|
||||
if (PLATFORM_DETECTORS[platformId]) {
|
||||
return PLATFORM_DETECTORS[platformId]()
|
||||
}
|
||||
|
||||
// 2. Generic Config-based Detection
|
||||
const config = LOGIN_CHECK_CONFIG[platformId]
|
||||
if (config) {
|
||||
if (config.useCookie || (config.cookieNames && config.cookieNames.length > 0)) {
|
||||
return checkLoginByCookie(platformId, config)
|
||||
}
|
||||
|
||||
// Default to API check if API is defined
|
||||
if (config.api) {
|
||||
return detectByApi(platformId, config)
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: false, error: 'No detection available' }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Alipay Open platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check chrome.storage.local cache (1 hour TTL)
|
||||
* 2. If cache miss, inject script into open Alipay tab to call API
|
||||
*/
|
||||
export async function detectAlipayUser() {
|
||||
const platformId = 'alipayopen'
|
||||
try {
|
||||
// 从 storage 读取缓存的用户信息
|
||||
const stored = await chrome.storage.local.get('alipayopen_user')
|
||||
const cachedUser = stored.alipayopen_user
|
||||
|
||||
if (cachedUser && cachedUser.loggedIn) {
|
||||
// 检查缓存是否过期(1小时)
|
||||
const cacheAge = Date.now() - (cachedUser.cachedAt || 0)
|
||||
const maxAge = 1 * 60 * 60 * 1000 // 1 hour
|
||||
|
||||
if (cacheAge < maxAge) {
|
||||
console.log(`[COSE] alipayopen 从缓存读取用户信息:`, cachedUser.username)
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: cachedUser.username || '',
|
||||
avatar: cachedUser.avatar || '',
|
||||
}
|
||||
} else {
|
||||
console.log(`[COSE] alipayopen 缓存已过期`)
|
||||
// 清除过期缓存
|
||||
await chrome.storage.local.remove('alipayopen_user')
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从已打开的支付宝页面获取用户信息并缓存
|
||||
let tabs = await chrome.tabs.query({ url: 'https://open.alipay.com/*' })
|
||||
if (tabs.length === 0) {
|
||||
tabs = await chrome.tabs.query({ url: 'https://*.alipay.com/*' })
|
||||
}
|
||||
|
||||
if (tabs.length > 0) {
|
||||
try {
|
||||
// 在已打开的页面上下文中调用 API
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
func: async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://developerportal.alipay.com/octopus/service.do',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||
},
|
||||
body: 'data=%5B%7B%7D%5D&serviceName=alipay.open.developerops.forum.user.query',
|
||||
}
|
||||
)
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const data = results?.[0]?.result
|
||||
console.log(`[COSE] alipayopen API 数据:`, data)
|
||||
|
||||
if (data?.stat === 'ok' && data?.data?.isLoginUser === 1) {
|
||||
const username = data.data.nickname || ''
|
||||
const avatar = data.data.avatar || ''
|
||||
|
||||
// 缓存用户信息
|
||||
await chrome.storage.local.set({
|
||||
alipayopen_user: {
|
||||
loggedIn: true,
|
||||
username,
|
||||
avatar,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[COSE] alipayopen 用户信息:`, username, avatar ? '有头像' : '无头像')
|
||||
return { loggedIn: true, username, avatar }
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[COSE] alipayopen 从页面获取用户信息失败:`, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[COSE] alipayopen 未检测到登录状态`)
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
console.log(`[COSE] alipayopen 检测失败:`, e.message)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号绑定只接受阿里云开发者接口返回的稳定 ID。
|
||||
* 昵称和头像只能用于展示,绝不能作为发布任务的账号标识。
|
||||
*/
|
||||
function parseAliyunAccount(response) {
|
||||
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
|
||||
const account = data?.user && typeof data.user === 'object' ? data.user : data
|
||||
const platformUid = normalizeString(account?.uccId)
|
||||
|| normalizeString(account?.ucc_id)
|
||||
|| normalizeString(account?.userId)
|
||||
|| normalizeString(account?.user_id)
|
||||
|| normalizeString(account?.uid)
|
||||
|| normalizeString(account?.aliuid)
|
||||
|| normalizeString(account?.aliUid)
|
||||
|| normalizeString(account?.id)
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,39 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Bilibili platform detection logic
|
||||
* Strategy:
|
||||
* 1. Call https://api.bilibili.com/x/web-interface/nav API
|
||||
* 2. Extract username (uname) and avatar (face)
|
||||
* 3. Convert hdslb.com avatar to base64 data URL to bypass CORS/ORB
|
||||
*/
|
||||
export async function detectBilibiliUser() {
|
||||
try {
|
||||
const response = await fetch('https://api.bilibili.com/x/web-interface/nav', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data?.code !== 0 || !data?.data?.isLogin) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
const username = data.data.uname || ''
|
||||
let avatar = data.data.face || ''
|
||||
|
||||
// Convert hdslb.com avatar to base64 data URL to bypass CORS/ORB
|
||||
if (avatar && avatar.includes('hdslb.com')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://www.bilibili.com/')
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
console.log(`[COSE] bilibili 检测失败:`, e.message)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Cnblogs (博客园) detection logic (offscreen approach)
|
||||
* Fetch https://account.cnblogs.com/user/userinfo via offscreen document,
|
||||
* where cookies are sent automatically in document context.
|
||||
*/
|
||||
export async function detectCnblogsUser() {
|
||||
try {
|
||||
console.log('[COSE] Cnblogs Detection: Starting (offscreen)')
|
||||
|
||||
if (typeof globalThis.__coseDetectCnblogs === 'function') {
|
||||
const result = await globalThis.__coseDetectCnblogs()
|
||||
if (result && result.loggedIn) {
|
||||
let avatar = result.avatar || ''
|
||||
if (avatar && avatar.includes('cnblogs.com')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://www.cnblogs.com/')
|
||||
}
|
||||
console.log('[COSE] Cnblogs: Logged in:', result.username)
|
||||
return { loggedIn: true, username: result.username || '', avatar }
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[COSE] Cnblogs: Not logged in')
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
console.error('[COSE] Cnblogs Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getCsdnBlogSlug(value) {
|
||||
const blogUrl = normalizeString(value)
|
||||
if (!blogUrl) return undefined
|
||||
|
||||
try {
|
||||
const url = new URL(blogUrl)
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'blog.csdn.net') return undefined
|
||||
|
||||
return normalizeString(url.pathname.split('/').filter(Boolean)[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
@@ -0,0 +1,135 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getCurrentAccountScopes(html) {
|
||||
return Array.from(html.matchAll(
|
||||
/"(?:currentUser|current_user|userInfo|user_info|account|profile)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,4000}\})/g,
|
||||
)).map(match => match[1])
|
||||
}
|
||||
|
||||
function readJsonField(scopes, fields) {
|
||||
for (const scope of scopes) {
|
||||
for (const field of fields) {
|
||||
const match = scope.match(new RegExp(`"${field}"\\s*:\\s*(?:"([^"]+)"|(\\d+))`))
|
||||
const value = normalizeString(match?.[1] || match?.[2])
|
||||
if (value) return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readHtmlAttribute(html, attributes) {
|
||||
for (const attribute of attributes) {
|
||||
const match = html.match(new RegExp(`${attribute}=["']([^"']+)["']`, 'i'))
|
||||
const value = normalizeString(match?.[1])
|
||||
if (value) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readAvatarUid(html) {
|
||||
const avatarSources = Array.from(html.matchAll(
|
||||
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
|
||||
)).map(match => match[1])
|
||||
|
||||
for (const source of avatarSources) {
|
||||
const match = source.match(/[?&](?:uid|user_id|userId)=(\d+)(?:[&#]|$)/)
|
||||
if (match?.[1]) return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readProfileUid(html) {
|
||||
return html.match(/(?:https?:\/\/blog\.51cto\.com)?\/u_(\d+)(?:[/?#"'])/i)?.[1]
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return normalizeString(String(value || '').replace(/<[^>]+>/g, '').replace(/ /gi, ' '))
|
||||
}
|
||||
|
||||
/**
|
||||
* 51CTO 账号绑定必须拿到创作者页中的稳定 UID。
|
||||
* 昵称和头像只用于展示,不能独立作为发布任务的账号标识。
|
||||
*/
|
||||
function parseCto51Account(html) {
|
||||
if (typeof html !== 'string') {
|
||||
return { platformUid: undefined, username: '', avatar: '' }
|
||||
}
|
||||
|
||||
const scopes = getCurrentAccountScopes(html)
|
||||
const avatar = readHtmlAttribute(html, ['data-avatar', 'data-avatar-url'])
|
||||
|| html.match(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/i)?.[1]
|
||||
|| ''
|
||||
const profileName = stripHtml(
|
||||
html.match(/<a\b[^>]*href=["'][^"']*\/u_\d+[^"']*["'][^>]*>([\s\S]{1,160}?)<\/a>/i)?.[1],
|
||||
)
|
||||
|
||||
return {
|
||||
platformUid: readHtmlAttribute(html, [
|
||||
'data-current-user-id',
|
||||
'data-current-userid',
|
||||
'data-user-id',
|
||||
'data-userid',
|
||||
'data-uid',
|
||||
])
|
||||
|| readJsonField(scopes, ['uid', 'userId', 'user_id', 'id'])
|
||||
|| readAvatarUid(html)
|
||||
|| readProfileUid(html),
|
||||
username: readHtmlAttribute(html, ['data-nickname', 'data-user-name', 'data-username'])
|
||||
|| readJsonField(scopes, ['nickname', 'userName', 'username', 'name'])
|
||||
|| profileName
|
||||
|| '',
|
||||
avatar,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在用户当前 Chrome 的 51CTO 站点上下文中读取个人空间页面。
|
||||
* 不读取、复制或上传 Cookie,浏览器仅向原站携带既有登录态。
|
||||
*/
|
||||
async function detectCto51User() {
|
||||
try {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '51CTO 账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://home.51cto.com/',
|
||||
'https://home.51cto.com/space',
|
||||
{ responseType: 'text', method: 'GET' },
|
||||
)
|
||||
const account = parseCto51Account(response?.body)
|
||||
let finalUrl
|
||||
try {
|
||||
finalUrl = new URL(response?.url || '')
|
||||
} catch {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| finalUrl.protocol !== 'https:'
|
||||
|| finalUrl.hostname !== 'home.51cto.com'
|
||||
|| !/^\/space\/?$/.test(finalUrl.pathname)
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '51CTO 账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectCto51User, parseCto51Account }
|
||||
@@ -0,0 +1,206 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
async function convertToBase64WithFallback(avatarUrl) {
|
||||
if (!avatarUrl) return ''
|
||||
|
||||
// Use shared utility only
|
||||
try {
|
||||
const converted = await convertAvatarToBase64(avatarUrl, 'https://www.douban.com/')
|
||||
if (converted && converted.startsWith('data:')) {
|
||||
return converted
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] douban 通用头像转换失败:', e.message)
|
||||
}
|
||||
|
||||
// Fallback: manual fetch with cookies
|
||||
try {
|
||||
const doubanCookies = await chrome.cookies.getAll({ domain: '.douban.com' })
|
||||
const cookieHeader = doubanCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
const imgResp = await fetch(avatarUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Referer: 'https://www.douban.com/',
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!imgResp.ok) {
|
||||
return avatarUrl
|
||||
}
|
||||
const blob = await imgResp.blob()
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return `data:${blob.type || 'image/jpeg'};base64,${btoa(binary)}`
|
||||
} catch (e) {
|
||||
console.log('[COSE] douban 手动头像转换失败:', e.message)
|
||||
return avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Douban platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check dbcl2 cookie on douban.com as login indicator
|
||||
* 2. Parse /mine/ HTML to get user info
|
||||
* 3. Fallback: derive uid from dbcl2 cookie
|
||||
* 4. If avatar missing but uid exists, fetch profile page
|
||||
*/
|
||||
export async function detectDoubanUser() {
|
||||
try {
|
||||
// 1. Check dbcl2 cookie as login indicator
|
||||
const dbcl2Cookie = await chrome.cookies.get({
|
||||
url: 'https://www.douban.com',
|
||||
name: 'dbcl2',
|
||||
})
|
||||
|
||||
if (!dbcl2Cookie || !dbcl2Cookie.value) {
|
||||
console.log('[COSE] douban 未找到登录 cookie,未登录')
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// Logged in — now try to get user details
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
let uid = ''
|
||||
let loginConfirmed = false
|
||||
|
||||
// 2. Parse /mine/ HTML to get user info
|
||||
try {
|
||||
const doubanCookies = await chrome.cookies.getAll({ domain: '.douban.com' })
|
||||
const cookieHeader = doubanCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
|
||||
const response = await fetch('https://www.douban.com/mine/', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const finalUrl = response.url || ''
|
||||
const html = await response.text()
|
||||
const redirectedToLogin =
|
||||
/\/accounts\/login/i.test(finalUrl) ||
|
||||
/name=["']form_email["']/i.test(html) ||
|
||||
/登录豆瓣|扫码登录/i.test(html)
|
||||
const hasUserSignals =
|
||||
/的账号</.test(html) ||
|
||||
/https?:\/\/www\.douban\.com\/people\/([^/"?#]+)\/?/.test(html) ||
|
||||
/\/people\/([^/"?#]+)\/?/.test(html) ||
|
||||
/doubanio\.com\/icon\//i.test(html)
|
||||
|
||||
loginConfirmed = !redirectedToLogin && hasUserSignals
|
||||
|
||||
if (!username) {
|
||||
const accountMatch = html.match(/>([^<\n]+)的账号</)
|
||||
if (accountMatch?.[1]) {
|
||||
username = accountMatch[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !uid) {
|
||||
const profileLinkMatch = html.match(/https?:\/\/www\.douban\.com\/people\/([^/"?#]+)\/?/)
|
||||
if (profileLinkMatch?.[1]) {
|
||||
uid = profileLinkMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatar) {
|
||||
const avatarMatch =
|
||||
html.match(/https?:\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i) ||
|
||||
html.match(/\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i) ||
|
||||
html.match(/\/icon\/up\d+-\d+\.jpg/i)
|
||||
if (avatarMatch?.[1]) {
|
||||
avatar = avatarMatch[1]
|
||||
} else if (avatarMatch?.[0]) {
|
||||
avatar = avatarMatch[0]
|
||||
}
|
||||
|
||||
if (avatar && avatar.startsWith('//')) {
|
||||
avatar = `https:${avatar}`
|
||||
} else if (avatar && avatar.startsWith('/icon/')) {
|
||||
avatar = `https://img3.doubanio.com${avatar}`
|
||||
}
|
||||
}
|
||||
|
||||
if (username || avatar || uid) {
|
||||
console.log('[COSE] douban 从 /mine/ HTML 获取用户信息:', username || uid)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] douban /mine/ 解析失败:', e.message)
|
||||
}
|
||||
|
||||
// 3. Fallback: derive uid from dbcl2 cookie as username placeholder
|
||||
if (loginConfirmed && !username && !uid && dbcl2Cookie.value) {
|
||||
const uidFromCookie = dbcl2Cookie.value.match(/"?([^:"]+):/)
|
||||
if (uidFromCookie?.[1]) {
|
||||
uid = uidFromCookie[1]
|
||||
}
|
||||
}
|
||||
|
||||
if (!loginConfirmed) {
|
||||
console.log('[COSE] douban 仅有 cookie,未确认登录态,按未登录处理')
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
if (!username && uid) {
|
||||
username = uid
|
||||
}
|
||||
|
||||
// 5. If avatar still missing but uid exists, fetch profile page and extract avatar
|
||||
if (!avatar && uid) {
|
||||
try {
|
||||
const doubanCookies = await chrome.cookies.getAll({ domain: '.douban.com' })
|
||||
const cookieHeader = doubanCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
const profileResp = await fetch(`https://www.douban.com/people/${uid}/`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (profileResp.ok) {
|
||||
const profileHtml = await profileResp.text()
|
||||
const profileAvatar =
|
||||
profileHtml.match(/https?:\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i) ||
|
||||
profileHtml.match(/\/\/img\d\.doubanio\.com\/icon\/[^"'\s<]+/i)
|
||||
if (profileAvatar?.[0]) {
|
||||
avatar = profileAvatar[0]
|
||||
console.log('[COSE] douban 从个人页补充头像成功')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] douban 从个人页补充头像失败:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (avatar && avatar.startsWith('//')) {
|
||||
avatar = `https:${avatar}`
|
||||
}
|
||||
|
||||
// Convert douban avatar to base64 if needed
|
||||
if (avatar && avatar.startsWith('http')) {
|
||||
try {
|
||||
avatar = await convertToBase64WithFallback(avatar)
|
||||
} catch (e) {
|
||||
console.log('[COSE] douban 头像转换失败:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie exists means logged in; return best-effort user details
|
||||
return { loggedIn: true, username: username || '', avatar: avatar || '' }
|
||||
} catch (e) {
|
||||
console.log('[COSE] douban 检测失败:', e.message)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Elecfans (电子发烧友) detection logic
|
||||
* API: /api/mobile/index.php?module=profile (Discuz standard mobile API)
|
||||
* Response: { Variables: { member_uid, space: { username, realname }, member_avatar } }
|
||||
* Uses chrome.cookies.getAll to attach cookies manually (SameSite workaround)
|
||||
*/
|
||||
export async function detectElecfansUser() {
|
||||
try {
|
||||
// Collect cookies for bbs.elecfans.com
|
||||
const cookies = await chrome.cookies.getAll({ domain: '.elecfans.com' })
|
||||
const bbsCookies = await chrome.cookies.getAll({ url: 'https://bbs.elecfans.com' })
|
||||
const allCookies = [...cookies, ...bbsCookies]
|
||||
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('; ')
|
||||
|
||||
if (!cookieStr) return { loggedIn: false }
|
||||
|
||||
const response = await fetch('https://bbs.elecfans.com/api/mobile/index.php?module=profile', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Cookie: cookieStr,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return { loggedIn: false }
|
||||
|
||||
const data = await response.json()
|
||||
if (!data?.Variables?.member_uid) return { loggedIn: false }
|
||||
|
||||
const username =
|
||||
data.Variables.space?.username ||
|
||||
data.Variables.space?.realname ||
|
||||
data.Variables.member_username ||
|
||||
''
|
||||
let avatar = data.Variables.member_avatar || ''
|
||||
|
||||
if (!username) return { loggedIn: false }
|
||||
|
||||
if (avatar) avatar = await convertAvatarToBase64(avatar, 'https://bbs.elecfans.com/')
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { convertAvatarToBase64, detectByApi } from '../utils.js'
|
||||
|
||||
const HUAWEICLOUD_API =
|
||||
'https://devdata.huaweicloud.com/rest/developer/fwdu/rest/developer/user/hdcommunityservice/v1/member/get-personal-info'
|
||||
|
||||
/**
|
||||
* Huawei Cloud platform detection logic
|
||||
* 直接通过 API + 手动附加 cookie 检测,无需打开华为云页面
|
||||
*/
|
||||
export async function detectHuaweiCloudUser() {
|
||||
try {
|
||||
const result = await detectByApi('huaweicloud', {
|
||||
api: HUAWEICLOUD_API,
|
||||
method: 'GET',
|
||||
checkLogin: data => data && data.memName,
|
||||
getUserInfo: data => ({
|
||||
username: data.memAlias || data.memName || '',
|
||||
avatar: data.memPhoto || '',
|
||||
}),
|
||||
})
|
||||
|
||||
if (result.loggedIn) {
|
||||
let avatar = result.avatar || ''
|
||||
if (avatar && avatar.startsWith('http')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://bbs.huaweicloud.com/')
|
||||
}
|
||||
// 更新缓存(仅用于头像 base64 缓存,不用于登录判断)
|
||||
await chrome.storage.local.set({
|
||||
huaweicloud_user: {
|
||||
loggedIn: true,
|
||||
username: result.username,
|
||||
avatar,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
return { loggedIn: true, username: result.username, avatar }
|
||||
}
|
||||
|
||||
// 未登录,清除可能过期的缓存
|
||||
await chrome.storage.local.remove('huaweicloud_user')
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
console.error('[COSE] HuaweiCloud Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Huawei Developer platform detection logic
|
||||
* Strategy (cache detection pattern):
|
||||
* 1. Check chrome.storage.local cache (7 days TTL) + cookie validation
|
||||
* 2. Try executeScript on open developer.huawei.com tab to call API with credentials
|
||||
* 3. Content script auto-caches user info when visiting huawei developer pages
|
||||
* 4. Fallback: check developer_userdata cookie existence for basic login status
|
||||
*/
|
||||
export async function detectHuaweiDevUser() {
|
||||
try {
|
||||
// 1. 先检查缓存
|
||||
const stored = await chrome.storage.local.get('huaweidev_user')
|
||||
const cachedUser = stored.huaweidev_user
|
||||
|
||||
if (cachedUser && cachedUser.loggedIn) {
|
||||
const cacheAge = Date.now() - (cachedUser.cachedAt || 0)
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
if (cacheAge < maxAge && cachedUser.username) {
|
||||
// 验证 cookie 是否仍然存在,防止用户已登出但缓存未过期的误判
|
||||
const userCookie = await chrome.cookies.get({
|
||||
url: 'https://developer.huawei.com',
|
||||
name: 'developer_userdata',
|
||||
})
|
||||
if (userCookie && userCookie.value) {
|
||||
console.log('[COSE] HuaweiDev: using cached user info:', cachedUser.username)
|
||||
let avatar = cachedUser.avatar || ''
|
||||
// 如果缓存中的头像还是原始 URL(旧缓存),转换为 base64 并更新缓存
|
||||
if (avatar && avatar.startsWith('http')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://developer.huawei.com/')
|
||||
await chrome.storage.local.set({ huaweidev_user: { ...cachedUser, avatar } })
|
||||
}
|
||||
return { loggedIn: true, username: cachedUser.username, avatar }
|
||||
}
|
||||
// cookie 已失效,清除缓存
|
||||
console.log('[COSE] HuaweiDev: cache exists but cookie gone, clearing cache')
|
||||
await chrome.storage.local.remove('huaweidev_user')
|
||||
} else {
|
||||
await chrome.storage.local.remove('huaweidev_user')
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试在已打开的华为开发者页面中检测
|
||||
const tabs = await chrome.tabs.query({ url: 'https://developer.huawei.com/*' })
|
||||
if (tabs.length > 0) {
|
||||
try {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
func: async () => {
|
||||
try {
|
||||
// 1. 从 DOM 获取社区用户名(真实昵称,非脱敏手机号)
|
||||
const userNameEl = document.querySelector('.user_name')
|
||||
const domUsername = userNameEl ? userNameEl.textContent.trim() : ''
|
||||
|
||||
// 2. 从 API 获取头像
|
||||
const cookies = document.cookie.split(';').map(c => c.trim())
|
||||
const udCookie = cookies.find(c => c.startsWith('developer_userdata='))
|
||||
if (!udCookie) {
|
||||
return domUsername ? { loggedIn: true, username: domUsername, avatar: '' } : null
|
||||
}
|
||||
|
||||
const udValue = decodeURIComponent(udCookie.split('=').slice(1).join('='))
|
||||
let csrfToken = ''
|
||||
try {
|
||||
const udJson = JSON.parse(udValue)
|
||||
csrfToken = udJson.csrf || udJson.csrftoken || ''
|
||||
} catch (e) {
|
||||
return domUsername ? { loggedIn: true, username: domUsername, avatar: '' } : null
|
||||
}
|
||||
if (!csrfToken) {
|
||||
return domUsername ? { loggedIn: true, username: domUsername, avatar: '' } : null
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const hdDate = now
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}/, '')
|
||||
|
||||
let avatar = ''
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'https://svc-drcn.developer.huawei.com/codeserver/Common/v1/delegate',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-hd-csrf': csrfToken,
|
||||
'x-hd-date': hdDate,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
svc: 'GOpen.User.getInfo',
|
||||
reqType: 0,
|
||||
reqJson: JSON.stringify({ queryRangeFlag: '00000000000001' }),
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
if (data && data.returnCode === '0' && data.resJson) {
|
||||
const userInfo = JSON.parse(data.resJson)
|
||||
avatar = userInfo.headPictureURL || ''
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* avatar fetch failed, continue with DOM username */
|
||||
}
|
||||
|
||||
// 优先使用 DOM 中的社区昵称
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: domUsername || '',
|
||||
avatar,
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const result = results?.[0]?.result
|
||||
if (result && result.loggedIn) {
|
||||
let avatar = result.avatar || ''
|
||||
if (avatar && avatar.startsWith('http')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://developer.huawei.com/')
|
||||
}
|
||||
const userInfo = { ...result, avatar, cachedAt: Date.now() }
|
||||
await chrome.storage.local.set({ huaweidev_user: userInfo })
|
||||
return { loggedIn: true, username: userInfo.username, avatar }
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] HuaweiDev: executeScript failed:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 没有打开的华为开发者页面,检查 developer_userdata cookie 并通过 offscreen fetch 获取用户信息
|
||||
const userCookie = await chrome.cookies.get({
|
||||
url: 'https://developer.huawei.com',
|
||||
name: 'developer_userdata',
|
||||
})
|
||||
if (userCookie && userCookie.value) {
|
||||
console.log(
|
||||
'[COSE] HuaweiDev: developer_userdata cookie found, trying offscreen fetch for user info'
|
||||
)
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
let apiSuccess = false
|
||||
try {
|
||||
const udValue = decodeURIComponent(userCookie.value)
|
||||
const udJson = JSON.parse(udValue)
|
||||
const csrfToken = udJson.csrftoken || udJson.csrf || ''
|
||||
if (csrfToken) {
|
||||
const now = new Date()
|
||||
const hdDate = now
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}/, '')
|
||||
|
||||
// 确保 offscreen document 存在
|
||||
try {
|
||||
await chrome.offscreen.createDocument({
|
||||
url: 'distribution/cose/offscreen.html',
|
||||
reasons: ['DOM_SCRAPING'],
|
||||
justification: 'Fetch Huawei Developer user info with cookies',
|
||||
})
|
||||
} catch (e) {
|
||||
// 已存在则忽略
|
||||
if (!e.message.includes('Only a single offscreen')) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'OFFSCREEN_FETCH',
|
||||
payload: {
|
||||
url: 'https://svc-drcn.developer.huawei.com/codeserver/Common/v1/delegate',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-hd-csrf': csrfToken,
|
||||
'x-hd-date': hdDate,
|
||||
},
|
||||
body: {
|
||||
svc: 'GOpen.User.getInfo',
|
||||
reqType: 0,
|
||||
reqJson: JSON.stringify({ queryRangeFlag: '00000000000001' }),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response?.success && response.data) {
|
||||
const data = response.data
|
||||
if (data.returnCode === '0' && data.resJson) {
|
||||
const userInfo = JSON.parse(data.resJson)
|
||||
username = userInfo.displayName || ''
|
||||
avatar = userInfo.headPictureURL || ''
|
||||
if (avatar && avatar.startsWith('http')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://developer.huawei.com/')
|
||||
}
|
||||
apiSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 offscreen document
|
||||
try {
|
||||
await chrome.offscreen.closeDocument()
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] HuaweiDev: offscreen fetch failed:', e.message)
|
||||
}
|
||||
|
||||
// API 成功才认为已登录,否则视为登录过期
|
||||
if (apiSuccess) {
|
||||
if (username) {
|
||||
const userInfo = { loggedIn: true, username, avatar, cachedAt: Date.now() }
|
||||
await chrome.storage.local.set({ huaweidev_user: userInfo })
|
||||
}
|
||||
return { loggedIn: true, username, avatar }
|
||||
} else {
|
||||
// API 失败,清除可能残留的缓存
|
||||
await chrome.storage.local.remove('huaweidev_user')
|
||||
console.log('[COSE] HuaweiDev: API verification failed, treating as logged out')
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
console.error('[COSE] HuaweiDev Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* InfoQ 账号识别只接受用户接口返回的稳定 UID。
|
||||
* 昵称和头像只用于展示,不能作为发布任务绑定依据。
|
||||
*/
|
||||
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 {
|
||||
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',
|
||||
},
|
||||
)
|
||||
const account = parseInfoQAccount(response?.body)
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| response?.body?.code !== 0
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : 'InfoQ 账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectInfoQUser, parseInfoQAccount }
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
* Strategy: Fetch settings JSON API to get nickname and avatar
|
||||
*/
|
||||
export async function detectJianshuUser() {
|
||||
try {
|
||||
console.log('[COSE] Jianshu Detection: Starting')
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '简书账号检测通道不可用' }
|
||||
}
|
||||
|
||||
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 account = parseJianshuAccount(response.body)
|
||||
if (!account) return { loggedIn: false }
|
||||
|
||||
let avatar = account.avatar
|
||||
|
||||
if (avatar && avatar.includes('jianshu.io')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://www.jianshu.com/')
|
||||
}
|
||||
|
||||
return { ...account, loggedIn: true, avatar }
|
||||
} catch (e) {
|
||||
console.error('[COSE] Jianshu Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Medium platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check sid/uid cookies
|
||||
* 2. Fetch stats page and extract username/avatar via regex
|
||||
*/
|
||||
export async function detectMediumUser() {
|
||||
try {
|
||||
const sidCookie = await chrome.cookies.get({ url: 'https://medium.com', name: 'sid' })
|
||||
const uidCookie = await chrome.cookies.get({ url: 'https://medium.com', name: 'uid' })
|
||||
|
||||
if (!sidCookie && !uidCookie) return { loggedIn: false }
|
||||
|
||||
const response = await fetch('https://medium.com/me/stats', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
const html = await response.text()
|
||||
const finalUrl = response.url
|
||||
|
||||
if (finalUrl.includes('/m/signin') || finalUrl.includes('?signIn')) return { loggedIn: false }
|
||||
|
||||
const profileMatch =
|
||||
html.match(/"username"\s*:\s*"([^"]+)"/) ||
|
||||
html.match(/href="https:\/\/medium\.com\/@([^"?\/]+)"/) ||
|
||||
html.match(/medium\.com\/@([a-zA-Z0-9_]+)/)
|
||||
|
||||
if (
|
||||
profileMatch &&
|
||||
profileMatch[1] &&
|
||||
profileMatch[1] !== 'gmail' &&
|
||||
profileMatch[1] !== 'medium'
|
||||
) {
|
||||
const username = profileMatch[1]
|
||||
|
||||
// Extract avatar via imageId from JSON data near the username
|
||||
let avatar = ''
|
||||
const imageIdMatch =
|
||||
html.match(
|
||||
new RegExp(`"imageId"\\s*:\\s*"([^"]+)"[^}]*"username"\\s*:\\s*"${username}"`)
|
||||
) ||
|
||||
html.match(new RegExp(`"username"\\s*:\\s*"${username}"[^}]*"imageId"\\s*:\\s*"([^"]+)"`))
|
||||
if (imageIdMatch) {
|
||||
avatar = `https://miro.medium.com/v2/resize:fill:64:64/${imageIdMatch[1]}`
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} else {
|
||||
return { loggedIn: true, username: '', avatar: '' }
|
||||
}
|
||||
} catch (e) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* ModelScope platform detection logic
|
||||
* Strategy:
|
||||
* 1. Try /api/v1/users/login/info API (correct endpoint found via network inspection)
|
||||
* 2. Fallback: find an open ModelScope tab and extract username/avatar from DOM
|
||||
* 3. Fallback: check for auth cookies on modelscope.cn
|
||||
* 4. Convert avatar to base64 to bypass CORS/ORB
|
||||
*/
|
||||
export async function detectModelScopeUser() {
|
||||
try {
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
|
||||
// Try the correct API endpoint (found via Chrome DevTools network inspection)
|
||||
try {
|
||||
const response = await fetch('https://modelscope.cn/api/v1/users/login/info', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data?.Success !== false && data?.Code !== 10019901001) {
|
||||
const user = data?.Data?.User || data?.Data || {}
|
||||
username =
|
||||
user.Nickname ||
|
||||
user.NickName ||
|
||||
user.Name ||
|
||||
user.nickname ||
|
||||
user.name ||
|
||||
user.Login ||
|
||||
user.login ||
|
||||
''
|
||||
avatar = user.Avatar || user.avatar || ''
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (username) {
|
||||
if (avatar) avatar = await convertAvatarToBase64(avatar, 'https://modelscope.cn/')
|
||||
return { loggedIn: true, username, avatar }
|
||||
}
|
||||
|
||||
// Fallback: extract from open tab DOM
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://modelscope.cn/*' })
|
||||
if (tabs.length > 0) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
func: () => {
|
||||
// Look for avatar img in the page
|
||||
let avatarSrc = ''
|
||||
const avatarSelectors = [
|
||||
'img[src*="avatar"]',
|
||||
'.ant-avatar img',
|
||||
'img[class*="avatar" i]',
|
||||
'img[class*="Avatar" i]',
|
||||
]
|
||||
for (const sel of avatarSelectors) {
|
||||
const img = document.querySelector(sel)
|
||||
if (img && img.src && !img.src.includes('data:image/svg')) {
|
||||
avatarSrc = img.src
|
||||
break
|
||||
}
|
||||
}
|
||||
// Look for username from the page's user info
|
||||
let name = ''
|
||||
// Try to get from the header/nav user dropdown area
|
||||
const allLinks = document.querySelectorAll('a[href*="/profile/"]')
|
||||
for (const a of allLinks) {
|
||||
const href = a.getAttribute('href') || ''
|
||||
const match = href.match(/\/profile\/([^/?#]+)/)
|
||||
if (match && match[1]) {
|
||||
name = match[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
// Also try my/overview link text or nearby elements
|
||||
if (!name) {
|
||||
const myLink = document.querySelector('a[href="/my/overview"]')
|
||||
if (myLink) {
|
||||
const parent = myLink.closest('[class*="dropdown"]') || myLink.parentElement
|
||||
if (parent) {
|
||||
const spans = parent.querySelectorAll('span')
|
||||
for (const s of spans) {
|
||||
const t = s.textContent.trim()
|
||||
if (
|
||||
t &&
|
||||
t.length > 1 &&
|
||||
t.length < 30 &&
|
||||
!['登录', '注册', '退出', '设置'].includes(t)
|
||||
) {
|
||||
name = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { username: name, avatar: avatarSrc }
|
||||
},
|
||||
})
|
||||
if (results?.[0]?.result) {
|
||||
username = results[0].result.username || ''
|
||||
avatar = results[0].result.avatar || ''
|
||||
}
|
||||
if (username || avatar) {
|
||||
if (avatar) avatar = await convertAvatarToBase64(avatar, 'https://modelscope.cn/')
|
||||
return { loggedIn: true, username, avatar }
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号绑定只接受开源中国账号接口返回的稳定 ID。
|
||||
* 昵称和头像仅用于展示,不能作为发布任务的账号标识。
|
||||
*/
|
||||
function parseOSChinaAccount(response) {
|
||||
const user = response?.result?.userVo && typeof response.result.userVo === 'object'
|
||||
? response.result.userVo
|
||||
: undefined
|
||||
|
||||
return {
|
||||
platformUid: normalizeString(user?.id)
|
||||
|| normalizeString(user?.uid)
|
||||
|| normalizeString(user?.userId)
|
||||
|| normalizeString(user?.user_id),
|
||||
username: normalizeString(user?.name)
|
||||
|| normalizeString(user?.nickname)
|
||||
|| normalizeString(user?.userName)
|
||||
|| '',
|
||||
avatar: normalizeString(user?.portraitUrl)
|
||||
|| normalizeString(user?.avatar)
|
||||
|| normalizeString(user?.avatarUrl)
|
||||
|| '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在用户当前 Chrome 的开源中国站点上下文中调用账号接口。
|
||||
* 不读取、复制或上传 Cookie,浏览器只向原站自动携带已有登录态。
|
||||
*/
|
||||
async function detectOSChinaUser() {
|
||||
try {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '开源中国账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://my.oschina.net/',
|
||||
'https://apiv1.oschina.net/oschinapi/user/myDetails',
|
||||
{
|
||||
responseType: 'json',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
const account = parseOSChinaAccount(response?.body)
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| response?.body?.success !== true
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '开源中国账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectOSChinaUser, parseOSChinaAccount }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Baidu Qianfan platform detection logic
|
||||
* Strategy:
|
||||
* 1. Read csrftoken from bce-user-info-ct-id cookie
|
||||
* 2. Call current user API with csrftoken header
|
||||
*/
|
||||
export async function detectQianfanUser() {
|
||||
try {
|
||||
console.log('[COSE] Qianfan Detection: Starting')
|
||||
|
||||
// Read csrftoken from cookie
|
||||
const csrfCookie = await chrome.cookies.get({
|
||||
url: 'https://qianfan.cloud.baidu.com',
|
||||
name: 'bce-user-info-ct-id',
|
||||
})
|
||||
const csrfToken = csrfCookie?.value ? csrfCookie.value.replace(/"/g, '') : ''
|
||||
|
||||
const response = await fetch('https://qianfan.cloud.baidu.com/api/community/user/current', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(csrfToken ? { csrftoken: csrfToken } : {}),
|
||||
},
|
||||
})
|
||||
if (!response.ok) return { loggedIn: false }
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success && data.result) {
|
||||
const username = data.result.displayName || data.result.nickname || ''
|
||||
let avatar = data.result.avatar || ''
|
||||
|
||||
if (avatar && avatar.includes('bdimg.com')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://qianfan.cloud.baidu.com/')
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} else {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[COSE] Qianfan Detection Error:', e)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function readField(html, fields) {
|
||||
for (const field of fields) {
|
||||
const match = html.match(new RegExp(`["']?${field}["']?\\s*[:=]\\s*(?:["']([^"']+)["']|(\\d+))`))
|
||||
const value = normalizeString(match?.[1] || match?.[2])
|
||||
if (value) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 企鹅号账号绑定只接受编辑器页面当前账号数据中的 userID。
|
||||
* 昵称、作者名和头像只用于显示,绝不能作为发布任务的账号标识。
|
||||
*/
|
||||
function parseQiehaoAccount(html) {
|
||||
if (typeof html !== 'string') {
|
||||
return { platformUid: undefined, username: '', avatar: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
platformUid: readField(html, ['mediaId', 'userID', 'userId']),
|
||||
username: readField(html, ['mediaName', 'nickName', 'nickname', 'userName', 'name']) || '',
|
||||
avatar: readField(html, ['header', 'avatarUrl', 'avatar']) || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅通过用户当前 Chrome 的企鹅号页面上下文读取已登录编辑器。
|
||||
* 浏览器会自行携带原站登录态;扩展不读取、复制或上传 Cookie、Token、CSRF。
|
||||
*/
|
||||
async function detectQiehaoUser() {
|
||||
try {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '企鹅号账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://om.qq.com/',
|
||||
'https://om.qq.com/main/creation/article',
|
||||
{
|
||||
responseType: 'text',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
const account = parseQiehaoAccount(response?.body)
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| !response?.url?.startsWith('https://om.qq.com/main/creation/article')
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '企鹅号账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectQiehaoUser, parseQiehaoAccount }
|
||||
@@ -0,0 +1,76 @@
|
||||
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: '' }
|
||||
}
|
||||
|
||||
try {
|
||||
const nextData = JSON.parse(matched[1])
|
||||
const globalState = nextData?.props?.pageProps?.initialState?.global || {}
|
||||
const user = globalState.sessionUser?.user || {}
|
||||
const sessionInfo = globalState.sessionInfo || {}
|
||||
|
||||
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)
|
||||
|| '',
|
||||
}
|
||||
} 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 }
|
||||
@@ -0,0 +1,75 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Sspai (少数派) platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check sspai_jwt_token cookie
|
||||
* 2. Call user info API with Bearer token
|
||||
*/
|
||||
export async function detectSspaiUser() {
|
||||
try {
|
||||
const jwtCookie = await chrome.cookies.get({
|
||||
url: 'https://sspai.com',
|
||||
name: 'sspai_jwt_token',
|
||||
})
|
||||
if (!jwtCookie || !jwtCookie.value) return { loggedIn: false }
|
||||
|
||||
const token = jwtCookie.value
|
||||
const response = await fetch('https://sspai.com/api/v1/user/info/get', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error === 0 && data.data?.nickname) {
|
||||
return { loggedIn: true, username: data.data.nickname, avatar: data.data.avatar || '' }
|
||||
} else {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
} catch (e) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getCurrentAccountScope(html) {
|
||||
const match = html.match(
|
||||
/"(?:session|currentUser|userInfo|creatorInfo)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\}){0,12000}\})/,
|
||||
)
|
||||
return match?.[1] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 腾讯云的账号绑定只能使用创作中心当前账号块中的稳定标识。
|
||||
* 页面上任意作者昵称、文章作者 uid 或“创作中心”文案都不能用于绑定账号。
|
||||
*/
|
||||
function parseTencentCloudAccount(html) {
|
||||
if (typeof html !== 'string') {
|
||||
return { platformUid: undefined, username: '', avatar: '' }
|
||||
}
|
||||
|
||||
const accountScope = getCurrentAccountScope(html)
|
||||
const readField = field => {
|
||||
const match = accountScope.match(new RegExp(`"${field}"\\s*:\\s*(?:"([^"]+)"|(\\d+))`))
|
||||
return normalizeString(match?.[1] || match?.[2])
|
||||
}
|
||||
|
||||
return {
|
||||
platformUid: readField('loginUid')
|
||||
|| readField('uin')
|
||||
|| readField('userId')
|
||||
|| readField('user_id')
|
||||
|| readField('uid')
|
||||
|| readField('creatorId')
|
||||
|| readField('creator_id')
|
||||
|| readField('id'),
|
||||
username: readField('nickname') || readField('name') || '',
|
||||
avatar: readField('avatarUrl') || readField('avatar') || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅借助用户当前 Chrome 中的腾讯云站点上下文读取创作中心页面。
|
||||
* 不读取、复制或上传 Cookie,也不接受昵称作为平台账号 UID。
|
||||
*/
|
||||
async function detectTencentCloudUser() {
|
||||
try {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: '腾讯云账号检测通道不可用' }
|
||||
}
|
||||
|
||||
const response = await fetchFromSiteContext(
|
||||
'https://cloud.tencent.com/developer/',
|
||||
'https://cloud.tencent.com/developer/creator',
|
||||
{
|
||||
responseType: 'text',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
const account = parseTencentCloudAccount(response?.body)
|
||||
|
||||
if (
|
||||
response?.status < 200
|
||||
|| response?.status >= 300
|
||||
|| !response?.url?.includes('/developer/creator')
|
||||
|| !account.platformUid
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return { loggedIn: true, ...account }
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : '腾讯云账号检测失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectTencentCloudUser, parseTencentCloudAccount }
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Twitter/X platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check auth_token/ct0 cookies on x.com
|
||||
* 2. Fetch home page HTML and extract screen_name/avatar via regex
|
||||
*/
|
||||
export async function detectTwitterUser() {
|
||||
try {
|
||||
const authTokenCookie = await chrome.cookies.get({ url: 'https://x.com', name: 'auth_token' })
|
||||
const ct0Cookie = await chrome.cookies.get({ url: 'https://x.com', name: 'ct0' })
|
||||
|
||||
if (!authTokenCookie) return { loggedIn: false }
|
||||
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('https://x.com/home', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'text/html' },
|
||||
})
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
const screenNameMatch = html.match(/"screen_name"\s*:\s*"([^"]+)"/)
|
||||
if (screenNameMatch) username = screenNameMatch[1]
|
||||
const avatarMatch = html.match(/"profile_image_url_https"\s*:\s*"([^"]+)"/)
|
||||
if (avatarMatch) avatar = avatarMatch[1].replace('_normal.', '_x96.')
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// If fetch failed or regex failed, try explicit fallback scraping logic (simplified here)
|
||||
// Note: Full scrape logic from background.js is complex.
|
||||
// We will assume basic fetch works or just return loggedIn:true if cookie exists
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Volcengine (火山引擎开发者社区) detection logic
|
||||
* API: /api/fe/v1/user (found via Chrome DevTools network inspection)
|
||||
* Response: { data: { name, avatar: { url } }, err_no: 0 }
|
||||
* Uses chrome.cookies.getAll to attach cookies manually since service worker
|
||||
* fetch with credentials:'include' doesn't reliably send SameSite cookies.
|
||||
*/
|
||||
export async function detectVolcengineUser() {
|
||||
try {
|
||||
// Collect cookies for volcengine.com to attach to API request
|
||||
const cookies = await chrome.cookies.getAll({ domain: '.volcengine.com' })
|
||||
const devCookies = await chrome.cookies.getAll({ url: 'https://developer.volcengine.com' })
|
||||
const allCookies = [...cookies, ...devCookies]
|
||||
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('; ')
|
||||
|
||||
if (!cookieStr) return { loggedIn: false }
|
||||
|
||||
const response = await fetch('https://developer.volcengine.com/api/fe/v1/user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Cookie: cookieStr,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return { loggedIn: false }
|
||||
|
||||
const data = await response.json()
|
||||
if (data?.err_no !== 0 || !data?.data?.name) return { loggedIn: false }
|
||||
|
||||
let username = data.data.name
|
||||
let avatar = data.data.avatar?.url || ''
|
||||
|
||||
if (avatar) avatar = await convertAvatarToBase64(avatar, 'https://developer.volcengine.com/')
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
function normalizeString(value) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 网易号账号识别只接受创作者侧返回的 wemediaId。
|
||||
* 昵称可能变化且并不唯一,不能作为账号绑定或发布交接的身份标识。
|
||||
*/
|
||||
function parseWangyihaoAccount(response) {
|
||||
const data = response?.data && typeof response.data === 'object' ? response.data : undefined
|
||||
const platformUid = normalizeString(data?.wemediaId)
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* WeChat Official Account platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check chrome.storage.local cache (1 hour TTL)
|
||||
* 2. Inject script into open mp.weixin.qq.com tab to read wx.data
|
||||
* 3. Fallback: fetch mp.weixin.qq.com HTML and parse nick_name/head_img
|
||||
*/
|
||||
export async function detectWechatUser() {
|
||||
const platformId = 'wechat'
|
||||
try {
|
||||
// 先检查缓存
|
||||
const stored = await chrome.storage.local.get('wechat_user')
|
||||
const cachedUser = stored.wechat_user
|
||||
|
||||
if (cachedUser && cachedUser.loggedIn) {
|
||||
const cacheAge = Date.now() - (cachedUser.cachedAt || 0)
|
||||
const maxAge = 1 * 60 * 60 * 1000 // 1 hour
|
||||
|
||||
if (cacheAge < maxAge && cachedUser.platformUid) {
|
||||
console.log(`[COSE] wechat 从缓存读取:`, cachedUser.username)
|
||||
return {
|
||||
loggedIn: true,
|
||||
platformUid: cachedUser.platformUid,
|
||||
username: cachedUser.username || '',
|
||||
avatar: cachedUser.avatar || '',
|
||||
}
|
||||
} else {
|
||||
await chrome.storage.local.remove('wechat_user')
|
||||
}
|
||||
}
|
||||
|
||||
// 优先尝试在已打开的微信公众号页面中检测
|
||||
const tabs = await chrome.tabs.query({ url: 'https://mp.weixin.qq.com/*' })
|
||||
if (tabs.length > 0) {
|
||||
try {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
func: () => {
|
||||
const wxData = window.wx?.data
|
||||
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 || '',
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
const result = results?.[0]?.result
|
||||
if (result && result.loggedIn) {
|
||||
const userInfo = { ...result, cachedAt: Date.now() }
|
||||
await chrome.storage.local.set({ wechat_user: userInfo })
|
||||
return {
|
||||
loggedIn: true,
|
||||
platformUid: userInfo.platformUid || '',
|
||||
username: userInfo.username || '',
|
||||
avatar: userInfo.avatar || '',
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[COSE] wechat 页面脚本执行失败:`, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 备用方案:fetch 首页并解析 HTML
|
||||
try {
|
||||
const response = await fetch('https://mp.weixin.qq.com/', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'text/html' },
|
||||
})
|
||||
const html = await response.text()
|
||||
|
||||
if (html.includes('请使用微信扫描') || html.includes('扫码登录')) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
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 && 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, platformUid, username, avatar }
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[COSE] wechat fetch 失败:`, e.message)
|
||||
}
|
||||
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
console.log(`[COSE] wechat 检测失败:`, e.message)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* Weibo platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check SUBP/ALF cookies on card.weibo.com
|
||||
* 2. Fetch editor page HTML and extract nick/avatar via regex
|
||||
*/
|
||||
export async function detectWeiboUser() {
|
||||
const platformId = 'weibo'
|
||||
try {
|
||||
// 检查 card.weibo.com 的 SUBP cookie
|
||||
const subpCookie = await chrome.cookies.get({
|
||||
url: 'https://card.weibo.com',
|
||||
name: 'SUBP',
|
||||
})
|
||||
|
||||
// 也检查 ALF cookie
|
||||
const alfCookie = await chrome.cookies.get({
|
||||
url: 'https://card.weibo.com',
|
||||
name: 'ALF',
|
||||
})
|
||||
|
||||
if (!subpCookie && !alfCookie) {
|
||||
console.log(`[COSE] weibo 未找到登录 cookie,未登录`)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// 有 cookie,通过 fetch HTML 获取用户信息
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
|
||||
try {
|
||||
// 获取所有相关 cookies 并手动添加到请求
|
||||
const weiboCookies = await chrome.cookies.getAll({ domain: '.weibo.com' })
|
||||
const cardCookies = await chrome.cookies.getAll({ domain: 'card.weibo.com' })
|
||||
const sinaCookies = await chrome.cookies.getAll({ domain: '.sina.com.cn' })
|
||||
const allCookies = [...weiboCookies, ...cardCookies, ...sinaCookies]
|
||||
const cookieString = allCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
|
||||
const response = await fetch('https://card.weibo.com/article/v5/editor', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Cookie: cookieString,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
const html = await response.text()
|
||||
|
||||
// 从 HTML 中提取用户名
|
||||
const nickMatch = html.match(/"nick"\s*:\s*"([^"]+)"/)
|
||||
if (nickMatch) {
|
||||
username = nickMatch[1]
|
||||
} else {
|
||||
// 深度查找 nick
|
||||
const altNickMatch = html.match(/\\"nick\\"\s*:\s*\\"([^\\"]+)\\"/)
|
||||
if (altNickMatch) {
|
||||
username = altNickMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 从 HTML 中提取头像
|
||||
const avatarMatch = html.match(/"avatar_large"\s*:\s*"([^"]+)"/)
|
||||
if (avatarMatch) {
|
||||
avatar = avatarMatch[1].replace(/\\/g, '')
|
||||
} else {
|
||||
const altAvatarMatch = html.match(/\\"avatar_large\\"\s*:\s*\\"([^\\"]+)\\"/)
|
||||
if (altAvatarMatch) {
|
||||
let rawAvatar = altAvatarMatch[1].replace(/\\\\\\\//g, '/')
|
||||
if (rawAvatar.includes('sinaimg.cn')) {
|
||||
avatar = rawAvatar.split('?')[0]
|
||||
} else {
|
||||
avatar = rawAvatar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[COSE] weibo 用户信息: ${username}`)
|
||||
} catch (e) {
|
||||
console.log(`[COSE] weibo 获取用户详情失败:`, e.message)
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// Convert sinaimg.cn avatar to base64 data URL to bypass CORS/ORB
|
||||
if (avatar && avatar.includes('sinaimg.cn')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://weibo.com/')
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
console.log(`[COSE] weibo 检测失败:`, e.message)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Xiaohongshu (Little Red Book) platform detection logic
|
||||
* Strategy:
|
||||
* 1. Check `a1` cookie on creator.xiaohongshu.com as login indicator
|
||||
* 2. Best-effort: fetch user info via offscreen document or open tab
|
||||
* 3. Fall back to chrome.storage.local cache for user details
|
||||
*/
|
||||
export async function detectXiaohongshuUser() {
|
||||
try {
|
||||
// 1. Check a1 cookie as login indicator (MV3 service worker compatible)
|
||||
const a1Cookie = await chrome.cookies.get({
|
||||
url: 'https://creator.xiaohongshu.com',
|
||||
name: 'a1',
|
||||
})
|
||||
if (!a1Cookie || !a1Cookie.value) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// Logged in — now try to get user details
|
||||
|
||||
// 2a. Try offscreen fetch (document context, cookies sent automatically)
|
||||
try {
|
||||
const offscreenDetect = globalThis.__coseDetectXiaohongshu
|
||||
if (offscreenDetect) {
|
||||
const offResult = await offscreenDetect()
|
||||
if (offResult && offResult.loggedIn) {
|
||||
// Update cache
|
||||
const userInfo = { ...offResult, cachedAt: Date.now() }
|
||||
await chrome.storage.local.set({ xiaohongshu_user: userInfo })
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: offResult.username || '',
|
||||
avatar: offResult.avatar || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] xiaohongshu offscreen detection failed:', e.message)
|
||||
}
|
||||
|
||||
// 2b. Try open tab injection
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://creator.xiaohongshu.com/*' })
|
||||
if (tabs.length > 0) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
func: async () => {
|
||||
try {
|
||||
const response = await fetch('https://creator.xiaohongshu.com/api/galaxy/user/info', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
if (data?.success === true && data?.code === 0 && data?.data?.userId) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: data.data.userName || data.data.redId || '',
|
||||
avatar: data.data.userAvatar || '',
|
||||
userId: data.data.userId,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
const result = results?.[0]?.result
|
||||
if (result && result.loggedIn) {
|
||||
const userInfo = { ...result, cachedAt: Date.now() }
|
||||
await chrome.storage.local.set({ xiaohongshu_user: userInfo })
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: userInfo.username || '',
|
||||
avatar: userInfo.avatar || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] xiaohongshu tab detection failed:', e.message)
|
||||
}
|
||||
|
||||
// 2c. Fall back to cache for user details
|
||||
const stored = await chrome.storage.local.get('xiaohongshu_user')
|
||||
const cachedUser = stored.xiaohongshu_user
|
||||
if (cachedUser && cachedUser.username) {
|
||||
const cacheAge = Date.now() - (cachedUser.cachedAt || 0)
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
if (cacheAge < maxAge) {
|
||||
console.log('[COSE] xiaohongshu 从缓存读取:', cachedUser.username)
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: cachedUser.username || '',
|
||||
avatar: cachedUser.avatar || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie exists but couldn't get user details — still logged in
|
||||
return { loggedIn: true, username: '', avatar: '' }
|
||||
} catch (e) {
|
||||
console.log('[COSE] xiaohongshu 检测失败:', e.message)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
BaijiahaoLoginConfig,
|
||||
JuejinLoginConfig,
|
||||
ToutiaoLoginConfig,
|
||||
ZhihuLoginConfig,
|
||||
} from './configs.js'
|
||||
import { detectCsdnUser } from './platforms/csdn.js'
|
||||
import { detectAliyunUser } from './platforms/aliyun.js'
|
||||
import { detectCto51User } from './platforms/cto51.js'
|
||||
import { detectInfoQUser } from './platforms/infoq.js'
|
||||
import { detectJianshuUser } from './platforms/jianshu.js'
|
||||
import { detectOSChinaUser } from './platforms/oschina.js'
|
||||
import { detectQiehaoUser } from './platforms/qiehao.js'
|
||||
import { detectSegmentFaultUser } from './platforms/segmentfault.js'
|
||||
import { detectSohuUser } from './platforms/sohu.js'
|
||||
import { detectTencentCloudUser } from './platforms/tencentcloud.js'
|
||||
import { detectWangyihaoUser } from './platforms/wangyihao.js'
|
||||
import { detectWechatUser } from './platforms/wechat.js'
|
||||
|
||||
const SITE_CONTEXT_CONFIGS = {
|
||||
juejin: {
|
||||
siteUrl: 'https://juejin.cn/',
|
||||
config: JuejinLoginConfig,
|
||||
},
|
||||
zhihu: {
|
||||
siteUrl: 'https://www.zhihu.com/',
|
||||
config: ZhihuLoginConfig,
|
||||
},
|
||||
toutiao: {
|
||||
siteUrl: 'https://mp.toutiao.com/',
|
||||
config: ToutiaoLoginConfig,
|
||||
},
|
||||
baijiahao: {
|
||||
siteUrl: 'https://baijiahao.baidu.com/',
|
||||
config: BaijiahaoLoginConfig,
|
||||
},
|
||||
}
|
||||
|
||||
const SUPPORTED_PLATFORM_DETECTORS = {
|
||||
csdn: detectCsdnUser,
|
||||
wechat: detectWechatUser,
|
||||
jianshu: detectJianshuUser,
|
||||
segmentfault: detectSegmentFaultUser,
|
||||
wangyi: detectWangyihaoUser,
|
||||
sohu: detectSohuUser,
|
||||
infoq: detectInfoQUser,
|
||||
tencentcloud: detectTencentCloudUser,
|
||||
aliyun: detectAliyunUser,
|
||||
'51cto': detectCto51User,
|
||||
oschina: detectOSChinaUser,
|
||||
qiehao: detectQiehaoUser,
|
||||
}
|
||||
|
||||
function normalizePlatformUid(value) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return undefined
|
||||
|
||||
const normalized = String(value).trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function normalizeAccount(account) {
|
||||
if (!account || typeof account !== 'object') {
|
||||
return { loggedIn: false, error: '平台账号检测未返回有效结果' }
|
||||
}
|
||||
|
||||
return {
|
||||
...account,
|
||||
platformUid: normalizePlatformUid(account.platformUid),
|
||||
}
|
||||
}
|
||||
|
||||
async function detectBySiteContext(platformId, siteConfig) {
|
||||
const fetchFromSiteContext = globalThis.__coseTabContextFetch
|
||||
if (typeof fetchFromSiteContext !== 'function') {
|
||||
return { loggedIn: false, error: `${platformId} 账号检测通道不可用` }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchFromSiteContext(siteConfig.siteUrl, siteConfig.config.api, {
|
||||
method: siteConfig.config.method || 'GET',
|
||||
responseType: siteConfig.config.isHtml ? 'text' : 'json',
|
||||
body: siteConfig.config.body,
|
||||
})
|
||||
|
||||
if (
|
||||
!response
|
||||
|| response.status < 200
|
||||
|| response.status >= 300
|
||||
|| !siteConfig.config.checkLogin(response.body)
|
||||
) {
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
return normalizeAccount({
|
||||
loggedIn: true,
|
||||
...(siteConfig.config.getUserInfo ? siteConfig.config.getUserInfo(response.body) : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
loggedIn: false,
|
||||
error: error instanceof Error ? error.message : `${platformId} 账号检测失败`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Account detection available to the AiToEarn distribution protocol.
|
||||
* Each detector only uses the signed-in site's current page context.
|
||||
*/
|
||||
export async function detectSupportedPlatformUser(platformId) {
|
||||
const detector = SUPPORTED_PLATFORM_DETECTORS[platformId]
|
||||
if (detector) return normalizeAccount(await detector())
|
||||
|
||||
const siteConfig = SITE_CONTEXT_CONFIGS[platformId]
|
||||
if (siteConfig) return detectBySiteContext(platformId, siteConfig)
|
||||
|
||||
return { loggedIn: false, error: '该平台未开放受控账号检测' }
|
||||
}
|
||||
|
||||
export { SITE_CONTEXT_CONFIGS, SUPPORTED_PLATFORM_DETECTORS }
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Convert an avatar URL to a base64 data URL to bypass CORS/ORB blocking.
|
||||
* The service worker can fetch with a custom Referer header.
|
||||
* @param {string} avatarUrl - The original avatar URL
|
||||
* @param {string} referer - The Referer header to use for the fetch
|
||||
* @returns {Promise<string>} - base64 data URL, or original URL if conversion fails
|
||||
*/
|
||||
export async function convertAvatarToBase64(avatarUrl, referer) {
|
||||
try {
|
||||
const imgResp = await fetch(avatarUrl, {
|
||||
headers: { Referer: referer },
|
||||
})
|
||||
if (imgResp.ok) {
|
||||
const blob = await imgResp.blob()
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
const base64 = btoa(binary)
|
||||
const mime = blob.type || 'image/jpeg'
|
||||
return `data:${mime};base64,${base64}`
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] avatar base64 conversion failed:', e.message)
|
||||
}
|
||||
return avatarUrl
|
||||
}
|
||||
|
||||
export async function logToStorage(msg, data = null) {
|
||||
if (data) {
|
||||
console.log(msg, data)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 Cookie 检测登录状态
|
||||
export async function checkLoginByCookie(platformId, config) {
|
||||
try {
|
||||
// 直接按名称查找 cookie
|
||||
const cookieMap = {}
|
||||
if (config.cookieNames) {
|
||||
for (const name of config.cookieNames) {
|
||||
const cookie = await chrome.cookies.get({
|
||||
url: config.cookieUrl || `https://${config.cookieDomain}`,
|
||||
name: name,
|
||||
})
|
||||
if (cookie) {
|
||||
cookieMap[name] = cookie.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[COSE] ${platformId} 找到的cookies:`, Object.keys(cookieMap))
|
||||
|
||||
const hasLoginCookie = config.cookieNames && config.cookieNames.some(name => cookieMap[name])
|
||||
|
||||
if (!hasLoginCookie) {
|
||||
console.log(`[COSE] ${platformId} 未找到登录 cookie`)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// 自定义 cookie 值检测逻辑(如 InfoQ)
|
||||
if (config.customCheck && config.checkCookieValue) {
|
||||
console.log(`[COSE] ${platformId} 使用自定义 cookie 检测`)
|
||||
const result = config.checkCookieValue(cookieMap)
|
||||
console.log(`[COSE] ${platformId} 自定义检测结果:`, result)
|
||||
return result
|
||||
}
|
||||
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
|
||||
// 如果配置了从 cookie 获取用户名
|
||||
if (config.getUsernameFromCookie && config.usernameCookie) {
|
||||
username = decodeURIComponent(cookieMap[config.usernameCookie] || '')
|
||||
}
|
||||
|
||||
// 使用平台配置的 fetchAvatar 回调获取头像
|
||||
if (config.fetchAvatar && typeof config.fetchAvatar === 'function') {
|
||||
try {
|
||||
const fetchedAvatar = await config.fetchAvatar(cookieMap)
|
||||
if (fetchedAvatar) {
|
||||
avatar = fetchedAvatar
|
||||
console.log(`[COSE] ${platformId} 找到头像:`, avatar)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[COSE] ${platformId} 获取头像失败:`, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
console.log(`[COSE] ${platformId} Cookie 检测失败:`, e.message)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 通用 API 检测逻辑
|
||||
export async function detectByApi(platformId, config) {
|
||||
try {
|
||||
console.log(`[COSE] ${platformId} 开始 API 检测: ${config.api}`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000)
|
||||
|
||||
// Collect cookies via chrome.cookies.getAll for MV3 service worker compatibility
|
||||
let cookieStr = ''
|
||||
try {
|
||||
const apiUrl = new URL(config.api)
|
||||
const domain = apiUrl.hostname.split('.').slice(-2).join('.')
|
||||
const domainCookies = await chrome.cookies.getAll({ domain: `.${domain}` })
|
||||
const urlCookies = await chrome.cookies.getAll({ url: config.api })
|
||||
const allCookies = [...domainCookies, ...urlCookies]
|
||||
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
|
||||
})
|
||||
cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
} catch (e) {
|
||||
console.log(`[COSE] ${platformId} cookie 收集失败:`, e.message)
|
||||
}
|
||||
|
||||
const apiUrl = new URL(config.api)
|
||||
const origin = apiUrl.origin
|
||||
|
||||
const fetchOptions = {
|
||||
method: config.method || 'GET',
|
||||
headers: {
|
||||
Accept: config.isHtml
|
||||
? 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
...(cookieStr ? { Cookie: cookieStr } : {}),
|
||||
Origin: origin,
|
||||
Referer: origin + '/',
|
||||
...(config.headers || {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
|
||||
if (config.body) {
|
||||
fetchOptions.body = config.body
|
||||
}
|
||||
|
||||
const response = await fetch(config.api, fetchOptions)
|
||||
clearTimeout(timeoutId)
|
||||
console.log(`[COSE] ${platformId} API 响应状态: ${response.status}`)
|
||||
|
||||
let data = null
|
||||
if (config.isHtml) {
|
||||
// 明确指定为 HTML 响应时,使用 text() 解析
|
||||
try {
|
||||
data = await response.text()
|
||||
} catch (e) {
|
||||
data = ''
|
||||
}
|
||||
} else {
|
||||
// 其他情况尝试 JSON 解析
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (e) {
|
||||
data = null
|
||||
}
|
||||
}
|
||||
// console.log(`[COSE] ${platformId} API 数据:`, data)
|
||||
|
||||
const loggedIn = config.checkLogin(data)
|
||||
// console.log(`[COSE] ${platformId} checkLogin 结果: ${loggedIn}`)
|
||||
if (loggedIn && config.getUserInfo) {
|
||||
const userInfo = config.getUserInfo(data)
|
||||
console.log(`[COSE] ${platformId} 用户信息:`, userInfo)
|
||||
return { loggedIn: true, ...userInfo }
|
||||
}
|
||||
return { loggedIn: !!loggedIn }
|
||||
} catch (error) {
|
||||
console.log(`[COSE] ${platformId} API 检测失败: ${error.message}`)
|
||||
return { loggedIn: false, error: error.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>COSE Offscreen</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="./offscreen.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
// Offscreen document for making fetch requests with cookies
|
||||
// This runs in a document context where credentials: 'include' actually works
|
||||
// (unlike the service worker where cookies are not sent/received automatically)
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'OFFSCREEN_PING') {
|
||||
sendResponse({ pong: true })
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_FETCH') {
|
||||
handleFetch(message.payload)
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_WARM_FETCH') {
|
||||
handleWarmFetch(message.payload)
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_API_FETCH') {
|
||||
handleApiFetch(message.payload)
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_DETECT_CNBLOGS') {
|
||||
handleDetectCnblogs()
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'OFFSCREEN_DETECT_XIAOHONGSHU') {
|
||||
handleDetectXiaohongshu()
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }))
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
async function handleFetch(payload) {
|
||||
const { url, method, headers, body } = payload
|
||||
const resp = await fetch(url, {
|
||||
method: method || 'POST',
|
||||
credentials: 'include',
|
||||
headers: headers || {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm-up fetch: makes a request with credentials: 'include' to trigger
|
||||
* the browser's cookie restoration (SSO, session cookies, etc.)
|
||||
* Returns status and response headers info, not the full body.
|
||||
*/
|
||||
async function handleWarmFetch(payload) {
|
||||
const { url, redirect } = payload
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
redirect: redirect || 'follow',
|
||||
})
|
||||
// Read a small portion to ensure the response is consumed
|
||||
const text = await resp.text()
|
||||
return {
|
||||
status: resp.status,
|
||||
url: resp.url,
|
||||
length: text.length,
|
||||
}
|
||||
} catch (e) {
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API fetch: makes a request with credentials: 'include' and returns the response body.
|
||||
* Used for API calls that need cookies automatically attached (since service worker
|
||||
* fetch() strips manually-set Cookie headers in MV3).
|
||||
*/
|
||||
async function handleApiFetch(payload) {
|
||||
const { url, method, headers, responseType, redirect } = payload
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: method || 'GET',
|
||||
credentials: 'include',
|
||||
headers: headers || {},
|
||||
redirect: redirect || 'follow',
|
||||
})
|
||||
const status = resp.status
|
||||
const finalUrl = resp.url
|
||||
let body = null
|
||||
if (responseType === 'json') {
|
||||
try {
|
||||
body = await resp.json()
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
} else {
|
||||
body = await resp.text()
|
||||
}
|
||||
return { status, url: finalUrl, body }
|
||||
} catch (e) {
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cnblogs detection: fetch account.cnblogs.com/user/userinfo in document context.
|
||||
* Cookies are sent automatically (unlike service worker fetch which strips Cookie headers).
|
||||
*/
|
||||
async function handleDetectCnblogs() {
|
||||
try {
|
||||
const resp = await fetch('https://account.cnblogs.com/user/userinfo', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!resp.ok) return { loggedIn: false }
|
||||
|
||||
const data = await resp.json()
|
||||
if (!data?.spaceUserId) return { loggedIn: false }
|
||||
|
||||
const username = data.displayName || ''
|
||||
let avatar = data.iconName || ''
|
||||
if (avatar && !avatar.startsWith('http')) {
|
||||
avatar = 'https:' + avatar
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Xiaohongshu detection: fetch creator API in document context.
|
||||
* Cookies are sent automatically with credentials: 'include'.
|
||||
*/
|
||||
async function handleDetectXiaohongshu() {
|
||||
try {
|
||||
const resp = await fetch('https://creator.xiaohongshu.com/api/galaxy/user/info', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!resp.ok) return { loggedIn: false }
|
||||
|
||||
const data = await resp.json()
|
||||
if (data?.success === true && data?.code === 0 && data?.data?.userId) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
username: data.data.userName || data.data.redId || '',
|
||||
avatar: data.data.userAvatar || '',
|
||||
userId: data.data.userId,
|
||||
}
|
||||
}
|
||||
return { loggedIn: false }
|
||||
} catch (e) {
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
-11
@@ -1,32 +1,105 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AiToEarn - 内容营销助手",
|
||||
"version": "1.0.0",
|
||||
"description": "智能内容提取与多平台一键发布助手,助力内容营销效率提升",
|
||||
"default_locale": "zh_CN",
|
||||
"version": "1.19.6",
|
||||
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"alarms",
|
||||
"clipboardRead",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
"debugger",
|
||||
"declarativeNetRequest",
|
||||
"notifications",
|
||||
"offscreen",
|
||||
"scripting",
|
||||
"tabGroups",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost:6061/*",
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*",
|
||||
"http://localhost:3002/*",
|
||||
"http://127.0.0.1:3002/*"
|
||||
"https://wxapi.frp.it1024.cc/*",
|
||||
"https://*.csdn.net/*",
|
||||
"https://*.juejin.cn/*",
|
||||
"https://*.jianshu.com/*",
|
||||
"https://*.segmentfault.com/*",
|
||||
"https://*.toutiao.com/*",
|
||||
"https://*.douban.com/*",
|
||||
"https://*.bilibili.com/*",
|
||||
"https://*.weibo.com/*",
|
||||
"https://*.sinaimg.cn/*",
|
||||
"https://mp.weixin.qq.com/*",
|
||||
"https://*.zhihu.com/*",
|
||||
"https://*.sspai.com/*",
|
||||
"https://cdnfile.sspai.com/*",
|
||||
"https://*.xueqiu.com/*",
|
||||
"https://*.eastmoney.com/*",
|
||||
"https://*.wordpress.com/*",
|
||||
"https://*.wordpress.org/*",
|
||||
"https://*.cnblogs.com/*",
|
||||
"https://*.oschina.net/*",
|
||||
"https://*.51cto.com/*",
|
||||
"https://*.infoq.cn/*",
|
||||
"https://*.baijiahao.baidu.com/*",
|
||||
"https://*.163.com/*",
|
||||
"https://*.cloud.tencent.com/*",
|
||||
"https://om.qq.com/*",
|
||||
"https://page.om.qq.com/*",
|
||||
"https://*.medium.com/*",
|
||||
"https://*.sohu.com/*",
|
||||
"https://*.aliyun.com/*",
|
||||
"https://*.huaweicloud.com/*",
|
||||
"https://*.huawei.com/*",
|
||||
"https://*.hicloud.com/*",
|
||||
"https://*.x.com/*",
|
||||
"https://*.twitter.com/*",
|
||||
"https://qianfan.cloud.baidu.com/*",
|
||||
"https://*.alipay.com/*",
|
||||
"https://*.modelscope.cn/*",
|
||||
"https://*.alicdn.com/*",
|
||||
"https://*.volcengine.com/*",
|
||||
"https://*.byteacctimg.com/*",
|
||||
"https://*.douyin.com/*",
|
||||
"https://*.xiaohongshu.com/*",
|
||||
"https://*.elecfans.com/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.svg",
|
||||
"48": "icons/icon48.svg",
|
||||
"128": "icons/icon128.svg"
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"default_title": "AiToEarn 内容营销助手"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"http://localhost:6061/*",
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*"
|
||||
],
|
||||
"js": ["distribution-page-bridge.js", "interaction-page-bridge.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"http://localhost:6061/*",
|
||||
"http://127.0.0.1:6061/*",
|
||||
"https://wx.frp.it1024.cc/*"
|
||||
],
|
||||
"js": ["distribution-bridge.js", "interaction-bridge.js"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
@@ -34,9 +107,9 @@
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.svg",
|
||||
"48": "icons/icon48.svg",
|
||||
"128": "icons/icon128.svg"
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"options_page": "options.html"
|
||||
}
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
|
||||
.btn-icon:hover{color:var(--text-primary)}
|
||||
.btn-ghost{background:transparent;color:var(--text-muted)}
|
||||
.btn-ghost:hover{color:var(--text-primary)}
|
||||
.platform-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.platform-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;max-height:420px;overflow-y:auto;padding-right:4px}
|
||||
.platform-item{cursor:pointer}
|
||||
.platform-item input{display:none}
|
||||
.platform-tag{display:flex;align-items:center;justify-content:center;padding:8px 6px;border-radius:var(--radius-sm);background:var(--bg-primary);border:1px solid var(--border-color);font-size:12px;font-weight:500;color:var(--text-secondary);transition:all var(--transition);user-select:none}
|
||||
@@ -61,4 +61,4 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
|
||||
.about-card{text-align:center;padding:20px}
|
||||
.about-text{font-size:13px;color:var(--text-secondary)}
|
||||
.about-sub{font-size:12px;color:var(--text-muted);margin-top:4px}
|
||||
.hidden{display:none!important}
|
||||
.hidden{display:none!important}
|
||||
|
||||
+14
-99
@@ -1,126 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AiToEarn 设置</title>
|
||||
<title>AiToEarn 扩展设置</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="options-container">
|
||||
<!-- Header -->
|
||||
<main class="options-container">
|
||||
<header class="options-header">
|
||||
<div class="header-left">
|
||||
<img src="icons/icon48.svg" alt="AiToEarn" class="logo">
|
||||
<img src="icons/icon48.png" alt="" class="logo">
|
||||
<div>
|
||||
<h1>AiToEarn 设置</h1>
|
||||
<p class="subtitle">配置后端连接与默认行为</p>
|
||||
<h1>AiToEarn 扩展设置</h1>
|
||||
<p class="subtitle">选择扩展弹窗默认勾选的平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- API Configuration -->
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
API 连接配置
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="apiUrl">服务器地址</label>
|
||||
<div class="input-row">
|
||||
<input type="url" id="apiUrl" class="input" placeholder="http://localhost:3002" value="">
|
||||
<button id="testConnection" class="btn btn-secondary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
<p class="help-text">AiToEarn 后端 API 服务地址,默认为 http://localhost:3002</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="apiKey">API 密钥</label>
|
||||
<div class="input-row">
|
||||
<input type="password" id="apiKey" class="input" placeholder="输入 API 密钥(可选)">
|
||||
<button id="toggleKeyVisibility" class="btn btn-icon" title="显示/隐藏密钥">
|
||||
<svg id="eyeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="help-text">用于身份验证的 API 密钥,留空则不使用</p>
|
||||
</div>
|
||||
|
||||
<div id="connectionStatus" class="connection-status hidden"></div>
|
||||
<h2 class="card-title">默认目标平台</h2>
|
||||
<div id="platformGrid" class="platform-grid" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- Default Behavior -->
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
默认行为
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">默认发布平台</label>
|
||||
<div class="platform-grid options-platforms">
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="wechat"><span class="platform-tag wechat">微信公众号</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="douyin"><span class="platform-tag douyin">抖音</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="xiaohongshu"><span class="platform-tag xiaohongshu">小红书</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="bilibili"><span class="platform-tag bilibili">B站</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="zhihu"><span class="platform-tag zhihu">知乎</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="juejin"><span class="platform-tag juejin">掘金</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="csdn"><span class="platform-tag csdn">CSDN</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="cnblogs"><span class="platform-tag cnblogs">博客园</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="twitter"><span class="platform-tag twitter">Twitter</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
<span>默认开启 AI 润色</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="defaultAutoPolish">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="help-text" style="margin-top: 6px;">开启后每次提取内容将自动进行 AI 润色</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="card actions-card">
|
||||
<div class="actions-row">
|
||||
<button id="saveBtn" class="btn btn-primary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
保存设置
|
||||
</button>
|
||||
<button id="resetBtn" class="btn btn-ghost">恢复默认</button>
|
||||
<button id="saveBtn" class="btn btn-primary" type="button">保存设置</button>
|
||||
<button id="resetBtn" class="btn btn-ghost" type="button">恢复默认</button>
|
||||
</div>
|
||||
<div id="saveStatus" class="save-status hidden"></div>
|
||||
<div id="saveStatus" class="save-status hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="card about-card">
|
||||
<p class="about-text">AiToEarn 内容营销助手 v1.0.0</p>
|
||||
<p class="about-sub">智能内容提取 · AI润色 · 多平台一键发布</p>
|
||||
<p class="about-text">AiToEarn 内容营销助手 v<span id="extensionVersion"></span></p>
|
||||
<p class="about-sub">扩展只打开并填充平台编辑器,最终发布由你确认</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
|
||||
+55
-100
@@ -1,119 +1,74 @@
|
||||
// ===== AiToEarn Options Page Script =====
|
||||
const platformGrid = document.querySelector('#platformGrid');
|
||||
const saveBtn = document.querySelector('#saveBtn');
|
||||
const resetBtn = document.querySelector('#resetBtn');
|
||||
const saveStatus = document.querySelector('#saveStatus');
|
||||
const extensionVersion = document.querySelector('#extensionVersion');
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:3002';
|
||||
let availablePlatforms = [];
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
const apiUrlInput = #apiUrl;
|
||||
const apiKeyInput = #apiKey;
|
||||
const testConnectionBtn = #testConnection;
|
||||
const toggleKeyVisibility = #toggleKeyVisibility;
|
||||
const connectionStatus = #connectionStatus;
|
||||
const defaultAutoPolish = #defaultAutoPolish;
|
||||
const saveBtn = #saveBtn;
|
||||
const resetBtn = #resetBtn;
|
||||
const saveStatus = #saveStatus;
|
||||
|
||||
// ===== Load Settings =====
|
||||
async function loadSettings() {
|
||||
const data = await chrome.storage.sync.get({
|
||||
apiUrl: DEFAULT_API_URL,
|
||||
apiKey: '',
|
||||
defaultPlatforms: ['wechat'],
|
||||
autoPolish: false
|
||||
});
|
||||
|
||||
apiUrlInput.value = data.apiUrl || DEFAULT_API_URL;
|
||||
apiKeyInput.value = data.apiKey || '';
|
||||
defaultAutoPolish.checked = data.autoPolish || false;
|
||||
|
||||
// Set platform checkboxes
|
||||
const saved = data.defaultPlatforms || [];
|
||||
('input[name="defaultPlatform"]').forEach(cb => {
|
||||
cb.checked = saved.includes(cb.value);
|
||||
});
|
||||
function getSelectedPlatforms() {
|
||||
return Array.from(document.querySelectorAll('input[name="defaultPlatform"]:checked'))
|
||||
.map(input => input.value);
|
||||
}
|
||||
|
||||
// ===== Save Settings =====
|
||||
async function saveSettings() {
|
||||
const platforms = Array.from(('input[name="defaultPlatform"]:checked')).map(cb => cb.value);
|
||||
function renderPlatforms(selectedIds) {
|
||||
platformGrid.replaceChildren();
|
||||
const selected = new Set(selectedIds);
|
||||
for (const platform of availablePlatforms) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'platform-item';
|
||||
|
||||
const settings = {
|
||||
apiUrl: apiUrlInput.value.trim() || DEFAULT_API_URL,
|
||||
apiKey: apiKeyInput.value.trim(),
|
||||
defaultPlatforms: platforms,
|
||||
autoPolish: defaultAutoPolish.checked
|
||||
};
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = 'defaultPlatform';
|
||||
input.value = platform.id;
|
||||
input.checked = selected.has(platform.id);
|
||||
|
||||
await chrome.storage.sync.set(settings);
|
||||
showSaveStatus('✓ 设置已保存');
|
||||
}
|
||||
const text = document.createElement('span');
|
||||
text.className = 'platform-tag';
|
||||
text.textContent = platform.title || platform.name || platform.id;
|
||||
|
||||
// ===== Test Connection =====
|
||||
async function testConnection() {
|
||||
const url = apiUrlInput.value.trim() || DEFAULT_API_URL;
|
||||
testConnectionBtn.disabled = true;
|
||||
testConnectionBtn.textContent = '测试中...';
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = 'Bearer ' + apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url + '/health', {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showConnectionStatus('success', '✓ 连接成功!服务器运行正常');
|
||||
} else {
|
||||
showConnectionStatus('error', '✗ 服务器响应异常 (HTTP ' + response.status + ')');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') {
|
||||
showConnectionStatus('error', '✗ 连接超时,请检查服务器地址');
|
||||
} else {
|
||||
showConnectionStatus('error', '✗ 无法连接到服务器: ' + err.message);
|
||||
}
|
||||
} finally {
|
||||
testConnectionBtn.disabled = false;
|
||||
testConnectionBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> 测试连接';
|
||||
label.append(input, text);
|
||||
platformGrid.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UI Helpers =====
|
||||
function showConnectionStatus(type, message) {
|
||||
connectionStatus.className = 'connection-status ' + type;
|
||||
connectionStatus.textContent = message;
|
||||
connectionStatus.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showSaveStatus(message) {
|
||||
saveStatus.className = 'save-status success';
|
||||
saveStatus.textContent = message;
|
||||
saveStatus.classList.remove('hidden');
|
||||
setTimeout(() => saveStatus.classList.add('hidden'), 3000);
|
||||
saveStatus.className = 'save-status success';
|
||||
window.setTimeout(() => saveStatus.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
// ===== Toggle API Key Visibility =====
|
||||
toggleKeyVisibility.addEventListener('click', () => {
|
||||
const isPassword = apiKeyInput.type === 'password';
|
||||
apiKeyInput.type = isPassword ? 'text' : 'password';
|
||||
async function loadSettings() {
|
||||
const [response, preferences] = await Promise.all([
|
||||
chrome.runtime.sendMessage({ type: 'GET_PLATFORMS' }),
|
||||
chrome.storage.sync.get({ defaultPlatforms: ['wechat'] }),
|
||||
]);
|
||||
if (response?.error) throw new Error(response.error);
|
||||
availablePlatforms = Array.isArray(response?.platforms)
|
||||
? 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));
|
||||
renderPlatforms(selectedIds.length > 0 ? selectedIds : ['wechat']);
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
await chrome.storage.sync.set({ defaultPlatforms: getSelectedPlatforms() });
|
||||
showSaveStatus('设置已保存');
|
||||
});
|
||||
|
||||
// ===== Event Listeners =====
|
||||
testConnectionBtn.addEventListener('click', testConnection);
|
||||
saveBtn.addEventListener('click', saveSettings);
|
||||
resetBtn.addEventListener('click', async () => {
|
||||
await chrome.storage.sync.clear();
|
||||
await loadSettings();
|
||||
showSaveStatus('✓ 已恢复默认设置');
|
||||
await chrome.storage.sync.set({ defaultPlatforms: ['wechat'] });
|
||||
renderPlatforms(['wechat']);
|
||||
showSaveStatus('已恢复默认设置');
|
||||
});
|
||||
|
||||
// ===== Init =====
|
||||
document.addEventListener('DOMContentLoaded', loadSettings);
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
extensionVersion.textContent = chrome.runtime.getManifest().version;
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch (error) {
|
||||
platformGrid.textContent = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "aitoearn-chrome-extension",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs && node scripts/test-browser-agent.mjs",
|
||||
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1",
|
||||
"release:verify": "node scripts/verify-release.mjs"
|
||||
}
|
||||
}
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
@@ -231,6 +231,10 @@ body {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -307,6 +311,16 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.platform-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
@@ -343,6 +357,9 @@ body {
|
||||
.checkbox-text {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
+33
-150
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -7,178 +7,61 @@
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Header -->
|
||||
<main class="app">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<img src="icons/icon48.svg" alt="AiToEarn" class="logo">
|
||||
<div>
|
||||
<h1 class="title">AiToEarn</h1>
|
||||
<p class="subtitle">内容营销助手</p>
|
||||
<div class="logo">
|
||||
<img src="icons/icon48.png" alt="" class="logo-icon">
|
||||
<div class="logo-text">
|
||||
<h1>AiToEarn</h1>
|
||||
<p class="tagline">内容营销助手</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="settingsBtn" class="icon-btn" title="设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
<button id="settingsBtn" class="btn-icon" type="button" title="默认平台设置" aria-label="默认平台设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.6.68 1.02 1.3 1.02H21a2 2 0 1 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Extract Button -->
|
||||
<section class="section">
|
||||
<button id="extractBtn" class="btn btn-extract">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<span>提取当前页面</span>
|
||||
<span id="extractStatus" class="btn-status"></span>
|
||||
<section class="extract-section">
|
||||
<button id="extractBtn" class="btn btn-secondary btn-extract" type="button">
|
||||
提取当前页面
|
||||
</button>
|
||||
<div id="extractStatus" class="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- Content Form -->
|
||||
<section class="section content-form" id="contentForm">
|
||||
<section class="editor-section">
|
||||
<div class="form-group">
|
||||
<label class="label" for="titleInput">标题</label>
|
||||
<input type="text" id="titleInput" class="input" placeholder="页面标题将自动填充...">
|
||||
<label for="titleInput">标题</label>
|
||||
<input type="text" id="titleInput" class="input" placeholder="输入标题或先提取当前页面">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="contentInput">正文内容</label>
|
||||
<textarea id="contentInput" class="textarea" rows="6" placeholder="页面内容将自动填充..."></textarea>
|
||||
<div class="char-count">
|
||||
<span id="charCount">0</span> 字
|
||||
</div>
|
||||
<label for="contentInput">正文内容</label>
|
||||
<textarea id="contentInput" class="textarea" rows="6" placeholder="输入正文或先提取当前页面"></textarea>
|
||||
<div class="char-count"><span id="charCount">0</span> 字</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">封面图片</label>
|
||||
<div id="imagePreview" class="image-preview hidden">
|
||||
<img id="previewImg" src="" alt="封面图片">
|
||||
<button id="removeImage" class="image-remove">×</button>
|
||||
</div>
|
||||
<div id="noImage" class="no-image">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<span>未检测到图片</span>
|
||||
<label>目标平台</label>
|
||||
<div id="platformGrid" class="platform-grid" aria-live="polite"></div>
|
||||
<div class="action-bar">
|
||||
<button id="selectAll" class="btn btn-secondary" type="button">全选</button>
|
||||
<button id="selectNone" class="btn btn-secondary" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Platform Selector -->
|
||||
<section class="section" id="platformSection">
|
||||
<label class="label">发布平台</label>
|
||||
<div class="platform-grid">
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="wechat" checked>
|
||||
<span class="platform-tag wechat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.986a.96.96 0 0 1 0 1.92.96.96 0 0 1 0-1.92zm5.812 0a.96.96 0 0 1 0 1.92.96.96 0 0 1 0-1.92z"/><path d="M23.928 14.57c0-3.39-3.256-6.15-7.273-6.15-4.057 0-7.273 2.76-7.273 6.15 0 3.39 3.216 6.15 7.273 6.15.848 0 1.658-.128 2.415-.347a.72.72 0 0 1 .594.082l1.58.923a.274.274 0 0 0 .14.047c.134 0 .243-.11.243-.245 0-.06-.024-.118-.04-.176l-.324-1.227a.493.493 0 0 1 .177-.549c1.526-1.117 2.488-2.78 2.488-4.658zm-9.495-.816a.798.798 0 0 1 0-1.596.798.798 0 0 1 0 1.596zm4.444 0a.798.798 0 0 1 0-1.596.798.798 0 0 1 0 1.596z"/></svg>
|
||||
微信公众号
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="douyin">
|
||||
<span class="platform-tag douyin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 0 0-.79-.05A6.34 6.34 0 0 0 3.15 15.2a6.34 6.34 0 0 0 10.86 4.43v-7.15a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-.81-.07l.01-.14z"/></svg>
|
||||
抖音
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="xiaohongshu">
|
||||
<span class="platform-tag xiaohongshu">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
小红书
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="bilibili">
|
||||
<span class="platform-tag bilibili">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.124.929.373.249.249.373.551.373.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373z"/></svg>
|
||||
B站
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="zhihu">
|
||||
<span class="platform-tag zhihu">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M5.721 0C2.251 0 0 2.25 0 5.719V18.28C0 21.751 2.252 24 5.721 24h12.56C21.751 24 24 21.75 24 18.281V5.72C24 2.249 21.75 0 18.281 0zm1.964 4.078h4.519c.154 0 .28.125.28.28v1.257a.28.28 0 0 1-.28.28H9.388a.28.28 0 0 1-.281-.28V4.358c0-.155.126-.28.281-.28zm-.56 5.374c0-.155.126-.281.281-.281h6.078a.28.28 0 0 1 .281.281v.558a.28.28 0 0 1-.281.282H7.407a.28.28 0 0 1-.282-.282v-.558zm.282 2.239h5.52a.28.28 0 0 1 .28.28v.559a.28.28 0 0 1-.28.281H7.688a.28.28 0 0 1-.281-.281v-.559c0-.154.126-.28.281-.28z"/></svg>
|
||||
知乎
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="juejin">
|
||||
<span class="platform-tag juejin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L1 12l11 10 11-10L12 2zm0 2.8L20.5 12 12 19.2 3.5 12 12 4.8z"/></svg>
|
||||
掘金
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="csdn">
|
||||
<span class="platform-tag csdn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>
|
||||
CSDN
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="cnblogs">
|
||||
<span class="platform-tag cnblogs">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
博客园
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="twitter">
|
||||
<span class="platform-tag twitter">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
Twitter
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select-actions">
|
||||
<button id="selectAll" class="link-btn">全选</button>
|
||||
<span class="divider">|</span>
|
||||
<button id="selectNone" class="link-btn">取消全选</button>
|
||||
<div class="action-bar">
|
||||
<button id="publishBtn" class="btn btn-primary btn-publish" type="button" disabled>
|
||||
打开并填充编辑器
|
||||
</button>
|
||||
</div>
|
||||
<div id="statusBar" class="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- AI Polish Toggle -->
|
||||
<section class="section">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
<span>AI 智能润色</span>
|
||||
<span class="toggle-hint">自动优化标题与正文</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="aiPolish">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Publish Button -->
|
||||
<section class="section">
|
||||
<button id="publishBtn" class="btn btn-publish" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
<span>一键发布</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div id="statusBar" class="status-bar hidden">
|
||||
<span id="statusIcon"></span>
|
||||
<span id="statusText"></span>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loadingOverlay" class="loading-overlay hidden">
|
||||
<div class="spinner"></div>
|
||||
<span id="loadingText">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">v<span id="extensionVersion"></span> · 填充后由你确认发布</footer>
|
||||
</main>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user