Compare commits

...

32 Commits

Author SHA1 Message Date
admin ef64467f6b fix(extension): 加固多平台内容填充完整性 2026-08-17 20:12:03 +08:00
admin c2d2a6c749 fix(extension): 加固掘金内容填充 2026-08-17 18:53:40 +08:00
admin 537df968a0 fix(extension): 补齐注入函数隔离边界 2026-08-17 11:48:35 +08:00
admin dd9e628ac2 fix(extension): 修复三平台注入填充 2026-08-17 11:23:41 +08:00
admin 679e949703 build(extension): 生成可复现发布包 2026-08-17 08:25:37 +08:00
admin 984d646e1d fix(extension): 修复多平台账号识别 2026-08-17 01:12:21 +08:00
admin 52e8f7f4a0 feat(extension): 支持声明式发布 Provider 快照 2026-08-16 19:41:45 +08:00
admin da8ad24f14 feat(extension): expand browser agent platform coverage 2026-08-16 17:50:30 +08:00
admin 6561378bc1 fix(extension): 适配 CSDN 当前标题编辑器 2026-08-16 17:23:54 +08:00
admin be650f6fab fix(extension): 支持腾讯云富文本编辑器交接 2026-08-16 17:10:31 +08:00
admin 8a8dcb0023 feat(extension): add browser agent pairing and qiehao handoff 2026-08-16 15:49:05 +08:00
admin 874455e5f2 feat(extension): add OSChina browser handoff 2026-08-16 12:35:23 +08:00
admin ae62ff02b6 docs(extension): update browser provider coverage 2026-08-16 12:06:25 +08:00
admin 5821c5711b feat(extension): add 51cto browser handoff 2026-08-16 12:01:07 +08:00
admin 6aea2f6810 feat(extension): 接入阿里云开发者浏览器交接 2026-08-16 11:21:10 +08:00
admin 1a94449988 chore(extension): verify released archive 2026-08-16 10:59:17 +08:00
admin 4f454f0ada feat(extension): add CSDN and SegmentFault handoff 2026-08-16 10:50:18 +08:00
admin 026b366f50 feat(extension): 接入腾讯云开发者社区交接 2026-08-16 05:08:13 +08:00
admin 61cdcc1afb fix(extension): block unverified platform auto-fill 2026-08-16 03:32:53 +08:00
admin e2244a20bf feat(extension): 接入InfoQ浏览器发布交接 2026-08-16 02:58:43 +08:00
admin 8612e86ba3 docs(extension): 更新搜狐号交接说明 2026-08-16 02:40:02 +08:00
admin 77d44bb52f feat(extension): 接入搜狐号受控发布交接 2026-08-16 02:34:23 +08:00
admin 7988fdab53 docs(extension): 更新网易号交接说明 2026-08-16 02:23:53 +08:00
admin c98c2d3864 feat(extension): 接入网易号受控发布交接 2026-08-16 02:19:03 +08:00
admin f6f2c03790 feat(extension): 接入百家号浏览器发布交接 2026-08-16 01:58:22 +08:00
admin 6f55d2c809 feat(extension): 接入今日头条浏览器发布交接 2026-08-16 01:45:20 +08:00
admin 1e48681c86 feat(extension): 接入知乎浏览器发布交接 2026-08-16 01:20:03 +08:00
admin fd192bf9ba feat(extension): 接入简书浏览器发布交接 2026-08-16 00:33:20 +08:00
admin bd963dea72 feat(extension): 完善浏览器发布交接与受控互动 2026-08-15 23:52:23 +08:00
admin 3c4fbf6310 feat(interaction): add controlled douyin comment bridge 2026-08-12 09:32:23 +08:00
admin ef620109cf feat(extension): add rendered page collection fallback 2026-08-01 19:14:30 +08:00
admin 9a5d299a1a feat(extension): 集成 COSE 多平台内容分发 2026-07-29 17:58:32 +08:00
106 changed files with 19435 additions and 701 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.zip binary
+3
View File
@@ -0,0 +1,3 @@
dist/
aitoearn-extension.zip
node_modules/
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+127
View File
@@ -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),
}, '*');
}
});
})();
+70
View File
@@ -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'));
})();
+728
View File
@@ -0,0 +1,728 @@
import { renderToutiaoMarkdown, renderToutiaoPlainText } from '../cose/core/platforms/toutiao.js'
import { AUTO_FILL_PLATFORM_IDS } from '../cose/core/platforms/index.js'
import {
getDeclarativeProviderAction,
validateBrowserProviderSnapshot,
} from '../cose/core/declarative-browser-provider.js'
const ALLOWED_API_ORIGINS = new Set([
'https://wxapi.frp.it1024.cc',
'http://localhost:3002',
])
const BROWSER_AGENT_STORAGE_KEY = 'aitoearn_browser_agent_v1'
const BROWSER_AGENT_ALARM_NAME = 'aitoearn-browser-agent-poll'
const BROWSER_AGENT_PLATFORM_IDS = [...AUTO_FILL_PLATFORM_IDS]
.filter(platformId => platformId !== 'wechat')
const PLATFORM_TAB_PATTERNS = {
csdn: ['https://editor.csdn.net/md/*'],
juejin: ['https://juejin.cn/*'],
jianshu: ['https://www.jianshu.com/*'],
zhihu: ['https://zhuanlan.zhihu.com/*', 'https://www.zhihu.com/*'],
toutiao: ['https://mp.toutiao.com/*'],
baijiahao: ['https://baijiahao.baidu.com/*'],
wangyi: ['https://mp.163.com/*'],
sohu: ['https://mp.sohu.com/*'],
infoq: ['https://xie.infoq.cn/*'],
tencentcloud: ['https://cloud.tencent.com/developer/*'],
aliyun: ['https://developer.aliyun.com/*'],
segmentfault: ['https://segmentfault.com/*'],
'51cto': ['https://blog.51cto.com/*'],
oschina: ['https://my.oschina.net/*'],
qiehao: ['https://om.qq.com/*'],
}
const PLATFORM_ACTIONS = {
csdn: {
draft: { selector: 'button.btn.btn-save', text: '保存草稿' },
publish: { selector: 'button.btn.btn-publish', text: '发布文章' },
},
tencentcloud: {
draft: { selector: 'button.cdc-btn.cdc-btn--hole', text: '存草稿' },
publish: { selector: 'button.cdc-btn.cdc-btn--primary', text: '去发布' },
},
aliyun: {
draft: { selector: 'button.public-button.gray-button', text: '存为草稿' },
publish: { selector: 'button.public-button.orange-button', text: '发布文章' },
},
qiehao: {
draft: { selector: 'button.omui-button.omui-button--background-grey', text: '存草稿' },
publish: { selector: 'button.omui-button.omui-button--primary', text: '发布' },
},
}
let pollingPromise = null
function normalizeApiBaseUrl(value) {
const rawValue = typeof value === 'string' && value.trim()
? value.trim()
: 'https://wxapi.frp.it1024.cc'
const url = new URL(rawValue)
if (url.pathname !== '/' || url.search || url.hash || !ALLOWED_API_ORIGINS.has(url.origin)) {
throw new Error('浏览器 Agent 只允许连接已配置的 AiToEarn API 地址')
}
return url.origin
}
function getPlatformAction(platformId, publishMode, providerConfig) {
if (providerConfig !== undefined) {
return getDeclarativeProviderAction(providerConfig, platformId, publishMode)
}
return PLATFORM_ACTIONS[platformId]?.[publishMode] || null
}
function getTaskExecutionPolicy(platformId, publishMode, openedStatus, providerConfig) {
if (openedStatus === 'published') return 'report_published'
const action = getPlatformAction(platformId, publishMode, providerConfig)
if (openedStatus === 'draft_saved' && (publishMode === 'draft' || !action)) {
return 'report_draft_saved'
}
if (action) return 'perform_action'
if (openedStatus === 'publication_uncertain') return 'report_uncertain'
return 'report_filled'
}
function getExtensionVersion() {
return chrome.runtime.getManifest().version
}
function getMessage(error, fallback) {
if (error instanceof Error && error.message) return error.message.slice(0, 1000)
return fallback
}
function unwrapApiPayload(payload) {
if (!payload || typeof payload !== 'object') return payload
if (typeof payload.code === 'number' && payload.code !== 0) {
throw new Error(typeof payload.message === 'string' ? payload.message : '浏览器 Agent 请求失败')
}
return payload.data === undefined ? payload : payload.data
}
async function requestBrowserAgent(agent, path, init = {}) {
const headers = new Headers(init.headers || {})
headers.set('Accept', 'application/json')
if (agent?.token) headers.set('x-aitoearn-browser-agent', agent.token)
if (init.body !== undefined) headers.set('Content-Type', 'application/json')
const response = await fetch(agent.apiBaseUrl + path, {
...init,
headers,
})
let payload
try {
payload = await response.json()
} catch {
throw new Error('AiToEarn API 返回了无效响应')
}
if (!response.ok) {
const message = payload && typeof payload === 'object' && typeof payload.message === 'string'
? payload.message
: '浏览器 Agent 请求失败:' + response.status
throw new Error(message)
}
return unwrapApiPayload(payload)
}
async function getStoredBrowserAgent() {
const result = await chrome.storage.local.get(BROWSER_AGENT_STORAGE_KEY)
const stored = result?.[BROWSER_AGENT_STORAGE_KEY]
if (
!stored
|| typeof stored !== 'object'
|| typeof stored.agentId !== 'string'
|| typeof stored.token !== 'string'
|| typeof stored.apiBaseUrl !== 'string'
) {
return null
}
return stored
}
async function saveStoredBrowserAgent(agent) {
await chrome.storage.local.set({
[BROWSER_AGENT_STORAGE_KEY]: agent,
})
}
function toPublicAgentState(agent, extra = {}) {
if (!agent) {
return {
paired: false,
...extra,
}
}
return {
paired: true,
agentId: agent.agentId,
apiBaseUrl: agent.apiBaseUrl,
pairedAt: agent.pairedAt,
runtime: agent.runtime || null,
...extra,
}
}
async function updateRuntimeState(agent, runtime) {
const next = {
...agent,
runtime: {
...agent.runtime,
...runtime,
updatedAt: new Date().toISOString(),
},
}
await saveStoredBrowserAgent(next)
return next
}
async function syncPollingAlarm() {
await chrome.alarms.create(BROWSER_AGENT_ALARM_NAME, {
periodInMinutes: 1,
})
}
async function pairBrowserAgent(payload) {
if (!payload || typeof payload !== 'object') throw new Error('缺少浏览器扩展配对信息')
if (typeof payload.agentId !== 'string' || !payload.agentId.trim()) throw new Error('缺少浏览器 Agent ID')
if (typeof payload.enrollmentCode !== 'string' || payload.enrollmentCode.length < 32) {
throw new Error('浏览器扩展配对码无效')
}
const apiBaseUrl = normalizeApiBaseUrl(payload.apiBaseUrl)
const exchange = await requestBrowserAgent(
{ apiBaseUrl },
'/v2/channels/browser-agents/enroll',
{
method: 'POST',
body: JSON.stringify({
agentId: payload.agentId,
enrollmentCode: payload.enrollmentCode,
extensionVersion: getExtensionVersion(),
}),
},
)
if (!exchange || typeof exchange.token !== 'string' || typeof exchange.agentId !== 'string') {
throw new Error('浏览器 Agent 配对响应无效')
}
const agent = {
agentId: exchange.agentId,
token: exchange.token,
apiBaseUrl,
pairedAt: new Date().toISOString(),
runtime: {
status: 'paired',
message: '浏览器 Agent 已配对,等待可执行任务',
},
}
await saveStoredBrowserAgent(agent)
await syncPollingAlarm()
return toPublicAgentState(agent)
}
async function getBrowserAgentStatus() {
const agent = await getStoredBrowserAgent()
if (!agent) return toPublicAgentState(null)
try {
const session = await requestBrowserAgent(agent, '/v2/channels/browser-agents/session/current')
const next = await updateRuntimeState(agent, {
status: 'online',
session,
lastError: undefined,
})
return toPublicAgentState(next, { session })
} catch (error) {
const next = await updateRuntimeState(agent, {
status: 'offline',
lastError: getMessage(error, '无法连接浏览器 Agent 服务'),
})
return toPublicAgentState(next)
}
}
async function disconnectBrowserAgent() {
await chrome.storage.local.remove(BROWSER_AGENT_STORAGE_KEY)
return { paired: false, disconnected: true }
}
async function hasCurrentPlatformTab(platformId) {
const patterns = PLATFORM_TAB_PATTERNS[platformId] || []
if (patterns.length === 0) return false
const tabs = await chrome.tabs.query({ url: patterns })
return tabs.some(tab => tab.id && !tab.discarded)
}
async function inspectCurrentPlatformAccount(platformId) {
const response = await chrome.runtime.sendMessage({
type: 'GET_PLATFORM_ACCOUNT',
platformId,
})
if (!response?.success || !response.platformUid) {
return {
account: null,
error: response?.error
|| (response?.loggedIn
? '已检测到登录状态,但平台没有返回稳定账号标识'
: '平台登录状态未通过核对'),
}
}
return {
account: {
platform: platformId,
platformUid: String(response.platformUid),
displayName: response.displayName || String(response.platformUid),
},
error: null,
}
}
async function getCurrentPlatformAccount(platformId) {
return (await inspectCurrentPlatformAccount(platformId)).account
}
async function inspectCurrentPlatformAccounts() {
const accounts = []
const checks = []
for (const platformId of BROWSER_AGENT_PLATFORM_IDS) {
if (!await hasCurrentPlatformTab(platformId)) continue
try {
const result = await inspectCurrentPlatformAccount(platformId)
if (result.account) {
accounts.push(result.account)
checks.push({
platform: platformId,
status: 'verified',
message: '账号已核对',
})
} else {
checks.push({
platform: platformId,
status: 'failed',
message: result.error,
})
}
} catch (error) {
checks.push({
platform: platformId,
status: 'failed',
message: getMessage(error, '平台账号检测失败'),
})
}
}
return { accounts, checks }
}
async function listCurrentPlatformAccounts() {
return (await inspectCurrentPlatformAccounts()).accounts
}
function toTaskContent(task) {
const body = typeof task.body === 'string' ? task.body : ''
const isHtml = /<[a-z][\w:-]*(?:\s[^<>]*)?>/i.test(body)
return {
title: typeof task.title === 'string' ? task.title : '',
body,
markdown: body,
html: isHtml ? body : renderToutiaoMarkdown(body),
plainText: isHtml ? body.replace(/<[^>]+>/g, ' ') : renderToutiaoPlainText(body),
}
}
function createTaskResult(task, account, status, extra = {}) {
return {
protocolVersion: 1,
status,
platformUid: account.platformUid,
extensionVersion: getExtensionVersion(),
...extra,
}
}
async function clickPlatformAction(platformId, tabId, publishMode, providerConfig) {
const action = getPlatformAction(platformId, publishMode, providerConfig)
if (!action) {
return {
success: false,
message: '该平台未定义受控' + (publishMode === 'draft' ? '存草稿' : '发布') + '动作',
}
}
const result = await chrome.scripting.executeScript({
target: { tabId },
func: (selector, text) => {
const normalize = value => String(value || '').replace(/\s+/g, '')
const button = Array.from(document.querySelectorAll(selector))
.find(candidate => normalize(candidate.textContent) === normalize(text))
if (!button) return { success: false, message: '未找到目标操作按钮' }
if (button.disabled || button.getAttribute('aria-disabled') === 'true') {
return { success: false, message: '目标操作按钮当前不可用' }
}
button.click()
return { success: true }
},
args: [action.selector, action.text],
world: 'MAIN',
})
return result?.[0]?.result || { success: false, message: '无法执行平台操作' }
}
async function inspectTaskState(task, tabId) {
return await chrome.runtime.sendMessage({
type: 'INSPECT_PLATFORM_TASK',
platformId: task.platform,
tabId,
expectedPlatformUid: task.platformUid,
})
}
async function executeClaimedTask(task) {
if (!BROWSER_AGENT_PLATFORM_IDS.includes(task.platform)) {
return {
report: false,
message: '当前扩展版本未开放该平台的浏览器 Agent 执行器',
}
}
const account = await getCurrentPlatformAccount(task.platform)
if (!account) {
return {
report: false,
message: '当前浏览器无法确认任务绑定的平台账号',
}
}
if (account.platformUid !== task.platformUid) {
return {
report: false,
message: '当前浏览器登录账号与任务绑定账号不一致,任务将等待租约过期后重新领取',
}
}
if (Date.parse(task.handoff?.expiresAt || '') <= Date.now()) {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
message: '浏览器发布任务租约已过期,未执行平台操作',
}),
}
}
let providerConfig
try {
providerConfig = task.handoff?.providerConfig === undefined
? undefined
: validateBrowserProviderSnapshot(task.handoff.providerConfig, task.platform)
} catch (error) {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
message: getMessage(error, '声明式 Browser Provider 配置无效'),
}),
}
}
let opened
try {
opened = await chrome.runtime.sendMessage({
type: 'OPEN_PLATFORM_TASK',
platformId: task.platform,
content: toTaskContent(task),
expectedPlatformUid: task.platformUid,
providerConfig,
})
} catch (error) {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
message: getMessage(error, '打开平台编辑器失败'),
}),
}
}
if (!opened?.success || !Number.isInteger(opened.tabId) || opened.tabId <= 0) {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
message: opened?.error || opened?.message || '平台编辑器内容填充失败',
}),
}
}
const executionPolicy = getTaskExecutionPolicy(
task.platform,
task.handoff.publishMode,
opened.status,
providerConfig,
)
if (executionPolicy !== 'perform_action') {
const status = {
report_published: 'published',
report_draft_saved: 'draft_saved',
report_uncertain: 'publication_uncertain',
report_filled: 'filled',
}[executionPolicy]
const fallbackMessage = status === 'filled'
? task.handoff.publishMode === 'publish'
? '内容已自动填充,当前平台发布动作尚未完成受控验证,请在已打开的编辑器中人工确认发布'
: '内容已自动填充,当前平台草稿动作尚未完成受控验证,请在已打开的编辑器中人工保存草稿'
: undefined
return {
report: true,
result: createTaskResult(task, account, status, {
tabId: opened.tabId,
url: opened.url,
message: opened.message || fallbackMessage,
}),
}
}
const clickResult = await clickPlatformAction(
task.platform,
opened.tabId,
task.handoff.publishMode,
providerConfig,
)
if (!clickResult.success) {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
tabId: opened.tabId,
url: opened.url,
message: clickResult.message || '平台操作未执行',
}),
}
}
await new Promise(resolve => setTimeout(resolve, 1600))
const inspected = await inspectTaskState(task, opened.tabId)
if (!inspected?.success && inspected?.status !== 'publication_uncertain') {
return {
report: true,
result: createTaskResult(task, account, 'failed', {
tabId: opened.tabId,
url: inspected?.url || opened.url,
message: inspected?.error || '无法核对平台操作结果',
}),
}
}
if (task.handoff.publishMode === 'draft') {
if (inspected.status === 'draft_saved') {
return {
report: true,
result: createTaskResult(task, account, 'draft_saved', {
tabId: opened.tabId,
url: inspected.url,
message: inspected.message,
}),
}
}
return {
report: true,
result: createTaskResult(task, account, 'publication_uncertain', {
tabId: opened.tabId,
url: inspected?.url || opened.url,
message: inspected?.message || '已点击存草稿,但平台未返回可确认的草稿结果',
}),
}
}
if (inspected.status === 'published') {
return {
report: true,
result: createTaskResult(task, account, 'published', {
tabId: opened.tabId,
url: inspected.url,
message: inspected.message,
}),
}
}
if (inspected.status === 'draft_saved') {
return {
report: true,
result: createTaskResult(task, account, 'draft_saved', {
tabId: opened.tabId,
url: inspected.url,
message: inspected.message,
}),
}
}
return {
report: true,
result: createTaskResult(task, account, 'publication_uncertain', {
tabId: opened.tabId,
url: inspected?.url || opened.url,
message: '已点击第一层发布按钮,但未确认最终公开结果;平台可能需要人工二次确认',
}),
}
}
async function pollBrowserAgentNow() {
if (pollingPromise) return pollingPromise
pollingPromise = (async () => {
const agent = await getStoredBrowserAgent()
if (!agent) return { success: false, status: 'not_paired' }
const { accounts, checks: accountChecks } = await inspectCurrentPlatformAccounts()
if (accounts.length === 0) {
const next = await updateRuntimeState(agent, {
status: 'waiting_for_platform_account',
message: accountChecks.length > 0
? `已打开 ${accountChecks.length} 个受支持平台,但账号核对未通过`
: '未找到已打开的受支持平台标签页',
lastError: undefined,
accounts: [],
accountChecks,
})
return toPublicAgentState(next, { success: true, accounts: [], accountChecks })
}
let claimed
try {
claimed = await requestBrowserAgent(agent, '/v2/channels/browser-agents/claim', {
method: 'POST',
body: JSON.stringify({
extensionVersion: getExtensionVersion(),
accounts: accounts.map(account => ({
platform: account.platform,
platformUid: account.platformUid,
})),
}),
})
} catch (error) {
const next = await updateRuntimeState(agent, {
status: 'offline',
lastError: getMessage(error, '领取浏览器任务失败'),
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: false, accounts, accountChecks })
}
const task = claimed?.task
if (!task) {
const next = await updateRuntimeState(agent, {
status: 'idle',
message: '没有等待执行的浏览器发布任务',
lastError: undefined,
lastClaimAt: new Date().toISOString(),
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: true, accounts, accountChecks, task: null })
}
const execution = await executeClaimedTask(task)
if (!execution.report) {
const next = await updateRuntimeState(agent, {
status: 'account_mismatch',
message: execution.message,
lastTaskId: task.taskId,
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
}
try {
await requestBrowserAgent(agent, '/v2/channels/browser-agents/' + encodeURIComponent(task.taskId) + '/result', {
method: 'POST',
body: JSON.stringify(execution.result),
})
const next = await updateRuntimeState(agent, {
status: execution.result.status,
message: execution.result.message,
lastError: undefined,
lastTaskId: task.taskId,
lastResultAt: new Date().toISOString(),
accounts,
accountChecks,
})
return toPublicAgentState(next, {
success: true,
accounts,
accountChecks,
taskId: task.taskId,
result: execution.result,
})
} catch (error) {
const next = await updateRuntimeState(agent, {
status: 'result_sync_failed',
message: '平台操作已完成,但结果回写失败',
lastError: getMessage(error, '浏览器任务结果回写失败'),
lastTaskId: task.taskId,
accounts,
accountChecks,
})
return toPublicAgentState(next, { success: false, accounts, accountChecks, taskId: task.taskId })
}
})().finally(() => {
pollingPromise = null
})
return pollingPromise
}
function installBrowserAgentRuntime() {
chrome.runtime.onInstalled.addListener(() => {
void syncPollingAlarm()
})
chrome.runtime.onStartup.addListener(() => {
void syncPollingAlarm()
})
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === BROWSER_AGENT_ALARM_NAME) {
void pollBrowserAgentNow()
}
})
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (![
'PAIR_BROWSER_AGENT',
'GET_BROWSER_AGENT_STATUS',
'POLL_BROWSER_AGENT_NOW',
'DISCONNECT_BROWSER_AGENT',
].includes(request?.type)) {
return false
}
;(async () => {
try {
switch (request.type) {
case 'PAIR_BROWSER_AGENT':
sendResponse(await pairBrowserAgent(request.payload))
return
case 'GET_BROWSER_AGENT_STATUS':
sendResponse(await getBrowserAgentStatus())
return
case 'POLL_BROWSER_AGENT_NOW':
sendResponse(await pollBrowserAgentNow())
return
case 'DISCONNECT_BROWSER_AGENT':
sendResponse(await disconnectBrowserAgent())
return
}
} catch (error) {
sendResponse({ error: getMessage(error, '浏览器 Agent 操作失败') })
}
})()
return true
})
void syncPollingAlarm()
}
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage && chrome.alarms) {
installBrowserAgentRuntime()
}
export {
ALLOWED_API_ORIGINS,
BROWSER_AGENT_PLATFORM_IDS,
PLATFORM_TAB_PATTERNS,
executeClaimedTask,
getPlatformAction,
getTaskExecutionPolicy,
inspectCurrentPlatformAccounts,
listCurrentPlatformAccounts,
normalizeApiBaseUrl,
toTaskContent,
}
+201
View File
@@ -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.
+10
View File
@@ -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 }
+375
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedRichText = html
? htmlToComparableText(html)
: normalizeComparableText(plainText || markdownContent)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
const sampleStarts = expectedRichText.length > sampleLength
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedRichText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors) {
for (const selector of selectors) {
const element = Array.from(document.querySelectorAll(selector))
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
if (element) return element
}
return null
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
function selectContents(element) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setInputValue(input, value) {
input.focus()
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function setRichTextValue(editor, nextHtml, nextPlainText) {
editor.focus()
selectContents(editor)
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedMarkdown(value) {
return normalizeContent(value) === normalizeContent(markdownContent)
}
function hasExpectedTitle(input) {
const expectedTitle = normalizeText(title)
return expectedTitle.length === 0 || normalizeText(input?.value) === expectedTitle
}
function hasExpectedRichText(editor) {
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
const imageCount = editor?.querySelectorAll?.('img').length || 0
const receivedText = expectedRichText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
return receivedText && receivedImages
}
async function switchToMarkdownEditor() {
const switchButton = Array.from(document.querySelectorAll('button, [role="button"], a'))
.find(element => {
const text = element.textContent?.replace(/\s+/g, '') || ''
return /切换.*(?:Markdown|MD)|(?:Markdown|MD).*编辑器/.test(text)
})
if (!switchButton) return
switchButton.click()
await sleep(800)
}
async function fill() {
const titleInput = await waitForElement([
'#title',
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'input[name="title"]',
'.title-input input',
'.article-title input',
])
if (!titleInput) return { success: false, error: '未找到阿里云开发者社区标题输入框' }
if (title) {
setInputValue(titleInput, title)
await sleep(200)
if (!hasExpectedTitle(titleInput)) {
return { success: false, error: '阿里云开发者社区未确认接收标题' }
}
}
if (!markdownContent) return { success: true, method: 'title-only' }
await switchToMarkdownEditor()
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
const editor = codeMirror?.CodeMirror
if (editor && typeof editor.setValue === 'function') {
editor.focus()
editor.setValue(markdownContent)
await sleep(400)
if (hasExpectedMarkdown(editor.getValue?.())) return { success: true, method: 'codemirror' }
}
const textarea = await waitForElement([
'.editor textarea.textarea',
'textarea#article-editor',
'textarea.textarea',
'.markdown-editor textarea',
'.editor-container textarea',
'textarea[placeholder*="正文"]',
'textarea',
], 4000)
if (textarea) {
setInputValue(textarea, markdownContent)
await sleep(300)
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: 'textarea' }
}
const richEditor = await waitForElement([
'.ProseMirror[contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
'.editor [contenteditable="true"]',
'[contenteditable="true"]',
], 4000)
if (richEditor) {
setRichTextValue(richEditor, html, plainText)
await sleep(500)
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
}
return {
success: false,
error: '阿里云开发者社区未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectAliyunTask(tab, helpers) {
const currentState = inspectAliyunTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'developer.aliyun.com'
&& /^\/article\/\d+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|文章已保存|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
return inspectAliyunTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncAliyunContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取阿里云开发者社区编辑器标签页' }
await helpers.waitForTab(tab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillAliyunContent,
args: [
content.title,
markdown,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '阿里云开发者社区内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已填充阿里云开发者社区编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
AliyunPlatform,
fillAliyunContent,
inspectAliyunTask,
inspectAliyunTaskState,
parseAliyunPublishedUrl,
syncAliyunContent,
}
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedText = htmlToComparableText(html)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
const sampleStarts = expectedText.length > sampleLength
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedText.slice(start, start + sampleLength)
)).filter(Boolean))]
function waitForCondition(getValue, timeout = 15000) {
return new Promise(resolve => {
const existing = getValue()
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const value = getValue()
if (!value) return
observer.disconnect()
resolve(value)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(getValue())
}, timeout)
})
}
function selectContents(element) {
const ownerDocument = element.ownerDocument
const selection = ownerDocument.defaultView?.getSelection()
const range = ownerDocument.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setEditableText(element, value) {
const ownerDocument = element.ownerDocument
element.focus()
selectContents(element)
ownerDocument.execCommand?.('insertText', false, value)
if (normalizeText(element.textContent) !== normalizeText(value)) element.textContent = value
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
function setEditableHtml(element, nextHtml, nextPlainText) {
const ownerDocument = element.ownerDocument
element.focus()
selectContents(element)
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
if (!inserted || normalizeText(element.textContent).length + element.querySelectorAll('img').length * 12 < 1) {
element.innerHTML = nextHtml
}
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
function findUeditor() {
const instances = window.UE_V2?.instants
if (!instances || typeof instances !== 'object') return undefined
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
}
function findEditorIframe() {
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
const body = iframe.contentDocument?.body
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
})
}
function hasExpectedContent(text, imageCount) {
const editorText = normalizeComparableText(text)
const receivedText = expectedText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
return receivedText && receivedImages
}
async function fill() {
const titleEditor = await waitForCondition(() => (
document.querySelector('.client_components_titleInput [contenteditable="true"]')
|| document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]')
|| document.querySelector('[class*="titleInput"] [contenteditable="true"]')
|| document.querySelector('[contenteditable="true"][placeholder*="标题"]')
))
if (!titleEditor) return { success: false, error: '未找到百家号标题输入框' }
if (title) {
setEditableText(titleEditor, title)
await sleep(200)
if (normalizeText(titleEditor.textContent) !== normalizeText(title)) {
return { success: false, error: '百家号未确认接收标题' }
}
}
if (!html) return { success: true, method: 'title-only' }
const editor = await waitForCondition(findUeditor, 7000)
if (editor) {
try {
editor.setContent(html)
editor.fireEvent?.('contentchange')
editor.fireEvent?.('selectionchange')
await sleep(500)
const serialized = editor.getContent?.() || ''
const text = editor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
const imageCount = (serialized.match(/<img\b/gi) || []).length
if (hasExpectedContent(text, imageCount)) {
return { success: true, method: 'ueditor', imageCount }
}
} catch {}
}
const iframe = await waitForCondition(findEditorIframe, 7000)
const iframeBody = iframe?.contentDocument?.body
if (iframeBody?.isContentEditable) {
setEditableHtml(iframeBody, html, plainText)
await sleep(500)
const imageCount = iframeBody.querySelectorAll('img').length
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
return { success: true, method: 'iframe', imageCount }
}
}
const contentEditor = Array.from(document.querySelectorAll('[contenteditable="true"]')).find(element => {
return element !== titleEditor && !titleEditor.contains(element) && !element.contains(titleEditor)
})
if (contentEditor) {
setEditableHtml(contentEditor, html, plainText)
await sleep(500)
const imageCount = contentEditor.querySelectorAll('img').length
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
return { success: true, method: 'contenteditable', imageCount }
}
}
return {
success: false,
error: '百家号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectBaijiahaoTask(tab, helpers) {
const currentState = inspectBaijiahaoTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
return url.hostname === 'baijiahao.baidu.com'
&& url.pathname === '/s'
&& Boolean(url.searchParams.get('id'))
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(link.textContent || '')
} catch {
return false
}
})
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(document.body?.innerText || ''),
}
},
})
const observed = result?.[0]?.result
const state = inspectBaijiahaoTaskState(tab.url || '', observed?.publicUrl)
if (state.status === 'filled' && observed?.draftSaved) {
return {
...state,
status: 'draft_saved',
message: '已确认百家号编辑器显示草稿保存成功',
}
}
return state
} catch {
return currentState
}
}
async function syncBaijiahaoContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取百家号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillBaijiahaoContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '百家号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充百家号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
BaijiahaoPlatform,
fillBaijiahaoContent,
inspectBaijiahaoTask,
inspectBaijiahaoTaskState,
parseBaijiahaoPublishedUrl,
syncBaijiahaoContent,
}
@@ -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'
+291
View File
@@ -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,
}
+333
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedRichText = html
? htmlToComparableText(html)
: normalizeComparableText(plainText || markdownContent)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
const sampleStarts = expectedRichText.length > sampleLength
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedRichText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors) {
for (const selector of selectors) {
const element = Array.from(document.querySelectorAll(selector))
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
if (element) return element
}
return null
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
function setInputValue(input, value) {
input.focus()
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function setRichTextValue(editor, nextHtml, nextPlainText) {
editor.focus()
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedMarkdown(value) {
return normalizeContent(value) === normalizeContent(markdownContent)
}
function hasExpectedRichText(editor) {
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
const imageCount = editor?.querySelectorAll?.('img').length || 0
const receivedText = expectedRichText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
return receivedText && receivedImages
}
async function fill() {
const titleInput = await waitForElement([
'#title',
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'input[name="title"]',
])
if (!titleInput) return { success: false, error: '未找到 51CTO 标题输入框' }
if (title) {
setInputValue(titleInput, title)
await sleep(100)
if (normalizeText(titleInput.value) !== normalizeText(title)) {
return { success: false, error: '51CTO 未确认接收标题' }
}
}
if (!markdownContent) return { success: true, method: 'title-only' }
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
const codeMirrorEditor = codeMirror?.CodeMirror
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
codeMirrorEditor.focus()
codeMirrorEditor.setValue(markdownContent)
await sleep(400)
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
return { success: true, method: 'codemirror' }
}
}
const markdownTextarea = await waitForElement([
'.editormd-markdown-textarea',
'textarea[name="content"]',
'textarea[placeholder*="正文"]',
'textarea',
], 4000)
if (markdownTextarea) {
setInputValue(markdownTextarea, markdownContent)
await sleep(300)
if (hasExpectedMarkdown(markdownTextarea.value)) {
return { success: true, method: 'markdown-textarea' }
}
}
const richEditor = await waitForElement([
'.ProseMirror[contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
'.editor [contenteditable="true"]',
'[contenteditable="true"]',
], 4000)
if (richEditor) {
setRichTextValue(richEditor, html, plainText)
await sleep(500)
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
}
return {
success: false,
error: '51CTO 未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectCto51Task(tab, helpers) {
const currentState = inspectCto51TaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'blog.51cto.com'
&& /^\/u_\d+\/\d+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const feedbackText = Array.from(document.querySelectorAll(
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
}
},
})
const observed = result?.[0]?.result
return inspectCto51TaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncCto51Content(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取 51CTO 编辑器标签页' }
await helpers.waitForTab(tab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillCto51Content,
args: [
content.title,
markdown,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '51CTO 内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已填充 51CTO 编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
CTO51Platform,
fillCto51Content,
inspectCto51Task,
inspectCto51TaskState,
parseCto51PublishedUrl,
syncCto51Content,
}
@@ -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 }
+202
View File
@@ -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,
}
+452
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedText = htmlToComparableText(html)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
const sampleStarts = expectedText.length > sampleLength
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors, excludedElement) {
for (const selector of selectors) {
const element = Array.from(document.querySelectorAll(selector))
.find(candidate => (
candidate !== excludedElement
&& !candidate.disabled
&& candidate.offsetParent !== null
))
if (element) return element
}
return null
}
function waitForElement(selectors, excludedElement, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors, excludedElement)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors, excludedElement)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors, excludedElement))
}, timeout)
})
}
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setInputValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = document.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
}
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedContent(editor) {
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
const imageCount = editor?.querySelectorAll?.('img').length || 0
const receivedText = expectedText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
return receivedText && receivedImages
}
function getVueCandidates(editor) {
const root = editor.closest?.('.gk-editor') || document.querySelector('.gk-editor')
return [
root?.__vue__,
root?.__vueParentComponent?.proxy,
editor.__vue__,
editor.__vueParentComponent?.proxy,
].filter(Boolean)
}
function dispatchHtmlPaste(editor) {
editor.focus()
selectEditorContents(editor)
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') return false
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', plainText)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
async function fill() {
const titleInput = await waitForElement([
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
'.title-input textarea',
'input.article-title',
'[contenteditable="true"][placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到 InfoQ 标题输入框' }
if (title) {
setInputValue(titleInput, title)
await sleep(200)
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
if (normalizeText(receivedTitle) !== normalizeText(title)) {
return { success: false, error: 'InfoQ 未确认接收标题' }
}
}
const editor = await waitForElement([
'.gk-editor [contenteditable="true"]',
'.gk-editor .ProseMirror',
'.ProseMirror[contenteditable="true"]',
'.ProseMirror',
'[contenteditable="true"][role="textbox"]',
], titleInput)
if (!editor) return { success: false, error: '未找到 InfoQ 正文编辑器' }
if (!contentToFill) return { success: true, method: 'title-only' }
for (const candidate of getVueCandidates(editor)) {
for (const method of [candidate.readMarkdown, candidate.editorAPI?.readMarkdown]) {
if (typeof method !== 'function') continue
try {
await Promise.resolve(method.call(method === candidate.readMarkdown ? candidate : candidate.editorAPI, contentToFill))
await sleep(800)
if (hasExpectedContent(editor)) {
return { success: true, method: 'markdown-api', imageCount: editor.querySelectorAll('img').length }
}
} catch {}
}
}
dispatchHtmlPaste(editor)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
if (!hasExpectedContent(editor) && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: plainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(500)
}
if (!hasExpectedContent(editor)) {
return {
success: false,
error: 'InfoQ 未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: 'paste-html',
imageCount: editor.querySelectorAll('img').length,
}
}
return fill()
}
async function inspectInfoQTask(tab, helpers) {
const currentState = inspectInfoQTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return ['xie.infoq.cn', 'www.infoq.cn'].includes(url.hostname)
&& /^\/article\/[A-Za-z0-9]+\/?$/.test(url.pathname)
&& /查看文章|阅读文章|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|内容已保存|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
return inspectInfoQTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
} catch {
return currentState
}
}
async function syncInfoQContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取 InfoQ 编辑器标签页' }
await helpers.waitForTab(tab.id)
const created = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: createInfoQDraft,
world: 'MAIN',
})
const draft = created?.[0]?.result
if (!draft?.success || !draft.draftId) {
return {
success: false,
message: draft?.error || 'InfoQ 创建草稿失败,请确认当前 Chrome 已登录创作平台',
tabId: tab.id,
}
}
const draftTab = await helpers.chrome.tabs.update(tab.id, {
url: `https://xie.infoq.cn/draft/${encodeURIComponent(draft.draftId)}`,
})
await helpers.waitForTab(draftTab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: draftTab.id },
func: fillInfoQContent,
args: [
content.title,
markdown,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || 'InfoQ 内容填充失败',
tabId: draftTab.id,
}
}
return {
success: true,
message: '已创建并填充 InfoQ 草稿,请检查后等待保存完成或手动发布',
tabId: draftTab.id,
}
}
export {
createInfoQDraft,
fillInfoQContent,
InfoQPlatform,
inspectInfoQTask,
inspectInfoQTaskState,
parseInfoQDraftCreation,
parseInfoQPublishedUrl,
syncInfoQContent,
}
+269
View File
@@ -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,
}
+198
View File
@@ -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 }
+317
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedRichText = html
? htmlToComparableText(html)
: normalizeComparableText(plainText || markdownContent)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
const sampleStarts = expectedRichText.length > sampleLength
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedRichText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors) {
for (const selector of selectors) {
const element = Array.from(document.querySelectorAll(selector))
.find(candidate => !candidate.disabled && candidate.offsetParent !== null)
if (element) return element
}
return null
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
function setInputValue(input, value) {
input.focus()
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function setRichTextValue(editor, nextHtml, nextPlainText) {
editor.focus()
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedMarkdown(value) {
return normalizeContent(value) === normalizeContent(markdownContent)
}
function hasExpectedRichText(editor) {
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
const imageCount = editor?.querySelectorAll?.('img').length || 0
const receivedText = expectedRichText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
return receivedText && receivedImages
}
async function switchToMarkdownEditor() {
const switchButton = Array.from(document.querySelectorAll('button, [role="button"], a'))
.find(element => {
const text = element.textContent?.replace(/\s+/g, '') || ''
return /切换到(?:Markdown|MD)编辑器|(?:Markdown|MD)编辑器/.test(text)
})
|| document.querySelector('.editor-switch-btn')
if (!switchButton) return
switchButton.click()
await sleep(500)
const confirmButton = Array.from(document.querySelectorAll('button')).find(button =>
/确定切换|确认切换/.test(button.textContent?.trim() || ''),
)
if (confirmButton) {
confirmButton.click()
await sleep(1200)
}
}
async function fill() {
const titleInput = await waitForElement([
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'input[name="title"]',
'.title-input input',
'.article-title input',
])
if (!titleInput) return { success: false, error: '未找到开源中国标题输入框' }
if (title) {
setInputValue(titleInput, title)
await sleep(100)
if (normalizeText(titleInput.value) !== normalizeText(title)) {
return { success: false, error: '开源中国未确认接收标题' }
}
}
if (!markdownContent) return { success: true, method: 'title-only' }
await switchToMarkdownEditor()
const codeMirror = await waitForElement(['.CodeMirror'], 5000)
const codeMirrorEditor = codeMirror?.CodeMirror
if (codeMirrorEditor && typeof codeMirrorEditor.setValue === 'function') {
codeMirrorEditor.focus()
codeMirrorEditor.setValue(markdownContent)
await sleep(400)
if (hasExpectedMarkdown(codeMirrorEditor.getValue?.())) {
return { success: true, method: 'codemirror' }
}
}
const textarea = await waitForElement([
'.editormd-markdown-textarea',
'.markdown-editor textarea',
'textarea[name="content"]',
'textarea[placeholder*="正文"]',
'textarea',
], 4000)
if (textarea) {
setInputValue(textarea, markdownContent)
await sleep(300)
if (hasExpectedMarkdown(textarea.value)) return { success: true, method: 'markdown-textarea' }
}
const richEditor = await waitForElement([
'.ProseMirror[contenteditable="true"]',
'.cke_editable[contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
'.editor [contenteditable="true"]',
'[contenteditable="true"]',
], 4000)
if (richEditor) {
setRichTextValue(richEditor, html, plainText)
await sleep(500)
if (hasExpectedRichText(richEditor)) return { success: true, method: 'rich-text' }
}
return {
success: false,
error: '开源中国未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectOSChinaTask(tab, helpers) {
const currentState = inspectOSChinaTaskState(tab?.url || '')
if (!tab?.id) return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const feedbackText = Array.from(document.querySelectorAll(
'[role="alert"], [role="status"], .el-message, .ant-message, .toast, .message',
)).filter(element => element.offsetParent !== null).map(element => element.textContent || '').join(' ')
return {
draftSaved: /草稿(?:已)?保存(?:成功)?|(?:已)?保存草稿(?:成功)?/.test(feedbackText),
}
},
})
return inspectOSChinaTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
} catch {
return currentState
}
}
async function syncOSChinaContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取开源中国编辑器标签页' }
await helpers.waitForTab(tab.id)
const markdown = content.markdown || content.body || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillOSChinaContent,
args: [
content.title,
markdown,
renderToutiaoMarkdown(markdown),
renderToutiaoPlainText(markdown),
],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '开源中国内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已填充开源中国编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
OSChinaPlatform,
fillOSChinaContent,
inspectOSChinaTask,
inspectOSChinaTaskState,
syncOSChinaContent,
}
+163
View File
@@ -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 }
+284
View File
@@ -0,0 +1,284 @@
const QiehaoPlatform = {
id: 'qiehao',
name: 'Qiehao',
icon: 'https://om.qq.com/favicon.ico',
url: 'https://om.qq.com/',
publishUrl: 'https://om.qq.com/main/creation/article',
title: '企鹅号',
type: 'qiehao',
}
function normalizeString(value) {
if (typeof value === 'number') return String(value)
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
function getQiehaoDraftId(url) {
return normalizeString(
url.searchParams.get('draftId')
|| url.searchParams.get('draft_id')
|| url.searchParams.get('articleId')
|| url.searchParams.get('article_id'),
)
}
function parseQiehaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
try {
const url = new URL(rawUrl)
if (url.protocol !== 'https:' || url.hostname !== 'page.om.qq.com') return null
const published = url.pathname.match(/^\/page\/([^/.]+)\.html$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认企鹅号文章公开地址',
}
} catch {
return null
}
}
function inspectQiehaoTaskState(rawUrl, draftSaved = false) {
const published = parseQiehaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取企鹅号编辑器标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'om.qq.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开企鹅号编辑器,无法确认发布结果',
}
}
if (url.pathname === '/main/creation/article') {
const draftId = getQiehaoDraftId(url)
if (draftSaved || draftId) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: draftId
? '已确认企鹅号草稿地址'
: '已确认企鹅号编辑器显示草稿保存成功',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到企鹅号编辑器,请检查后保存草稿或发布',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前企鹅号页面无法确认是草稿还是已发布文章',
}
}
function fillQiehaoContent(title, html, plainText) {
const contentHtml = html || ''
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
const normalizeText = value => String(value || '').replace(/\s+/g, '')
const normalizeComparableText = value => String(value || '')
.normalize('NFKC')
.replace(/[^\p{L}\p{N}]+/gu, '')
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
function htmlToComparableText(value) {
const entities = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedRichText = contentHtml
? htmlToComparableText(contentHtml)
: normalizeComparableText(contentText)
const expectedImageCount = Array.from(contentHtml.matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
const sampleStarts = expectedRichText.length > sampleLength
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedRichText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors) {
return selectors
.map(selector => document.querySelector(selector))
.find(element => element && element.offsetParent !== null)
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
function selectContents(element) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
}
function updateEditor(editor, nextHtml, nextText) {
editor.focus()
selectContents(editor)
const inserted = document.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
editor.dispatchEvent(new Event('blur', { bubbles: true }))
}
function hasExpectedTitle(editor) {
const expectedTitle = normalizeText(title)
return expectedTitle.length === 0 || normalizeText(editor?.textContent) === expectedTitle
}
function hasExpectedContent(editor) {
const editorText = normalizeComparableText(editor?.innerText || editor?.textContent)
const actualImageCount = editor?.querySelectorAll?.('img').length || 0
const receivedText = expectedRichText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || actualImageCount >= expectedImageCount
return receivedText && receivedImages
}
async function fill() {
const titleEditor = await waitForElement([
'.omui-inputautogrowing__inner[contenteditable="true"][data-placeholder*="请输入标题"]',
])
if (!titleEditor) return { success: false, error: '未找到企鹅号标题输入框' }
if (title) {
updateEditor(titleEditor, String(title).replace(/</g, '&lt;'), title)
await sleep(200)
if (!hasExpectedTitle(titleEditor)) return { success: false, error: '企鹅号未确认接收标题' }
}
if (!contentHtml) return { success: true, method: 'title-only' }
const articleEditor = await waitForElement([
'.ProseMirror.ExEditor-basic[contenteditable="true"]',
'.ProseMirror[contenteditable="true"]',
])
if (!articleEditor) return { success: false, error: '未找到企鹅号正文编辑器' }
updateEditor(articleEditor, contentHtml, contentText)
await sleep(500)
if (!hasExpectedContent(articleEditor)) {
return {
success: false,
error: '企鹅号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return { success: true, method: 'rich-text' }
}
return fill()
}
async function inspectQiehaoTask(tab, helpers) {
const currentState = inspectQiehaoTaskState(tab?.url || '')
if (!tab?.id) return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => ({
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功|草稿保存成功/.test(
document.body?.innerText || '',
),
}),
})
return inspectQiehaoTaskState(tab.url || '', result?.[0]?.result?.draftSaved)
} catch {
return currentState
}
}
async function syncQiehaoContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取企鹅号编辑器标签页' }
await helpers.waitForTab(tab.id)
const html = content.html || content.body || content.markdown || ''
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillQiehaoContent,
args: [content.title, html, content.plainText || html.replace(/<[^>]+>/g, ' ')],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '企鹅号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已填充企鹅号文章编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
fillQiehaoContent,
inspectQiehaoTask,
inspectQiehaoTaskState,
parseQiehaoPublishedUrl,
QiehaoPlatform,
syncQiehaoContent,
}
@@ -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,
}
+356
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedText = htmlToComparableText(html)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
const sampleStarts = expectedText.length > sampleLength
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedText.slice(start, start + sampleLength)
)).filter(Boolean))]
function waitForCondition(getValue, timeout = 15000) {
return new Promise(resolve => {
const existing = getValue()
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const value = getValue()
if (!value) return
observer.disconnect()
resolve(value)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(getValue())
}, timeout)
})
}
function selectEditorContents(editor) {
const ownerDocument = editor.ownerDocument
const selection = ownerDocument.defaultView?.getSelection()
const range = ownerDocument.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function setTitleValue(input, value) {
input.focus()
if (input.isContentEditable) {
selectEditorContents(input)
const inserted = input.ownerDocument.execCommand?.('insertText', false, value)
if (!inserted || normalizeText(input.textContent) !== normalizeText(value)) input.textContent = value
} else {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) setter.call(input, value)
else input.value = value
}
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: value,
inputType: 'insertText',
}))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function findUeditor() {
const instances = window.UE?.instants || window.UE?.instances
if (!instances || typeof instances !== 'object') return undefined
return Object.values(instances).find(editor => typeof editor?.setContent === 'function')
}
function findEditorIframe() {
return Array.from(document.querySelectorAll('iframe')).find(iframe => {
const body = iframe.contentDocument?.body
return body?.isContentEditable || /ueditor/i.test(`${iframe.id} ${iframe.name}`)
})
}
function findContentEditor(titleInput) {
return Array.from(document.querySelectorAll('[contenteditable="true"], .edui-body')).find(element => {
return element !== titleInput && !titleInput.contains(element) && !element.contains(titleInput)
})
}
function hasExpectedContent(text, imageCount) {
const editorText = normalizeComparableText(text)
const receivedText = expectedText.length === 0 || (
editorText.length >= minimumTextLength
&& expectedSamples.every(sample => editorText.includes(sample))
)
const receivedImages = expectedImageCount === 0 || imageCount >= expectedImageCount
return receivedText && receivedImages
}
function setEditableHtml(editor, nextHtml, nextPlainText) {
const ownerDocument = editor.ownerDocument
editor.focus()
selectEditorContents(editor)
const inserted = ownerDocument.execCommand?.('insertHTML', false, nextHtml)
if (!inserted) editor.innerHTML = nextHtml
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: nextPlainText,
inputType: 'insertFromPaste',
}))
editor.dispatchEvent(new Event('change', { bubbles: true }))
}
async function fill() {
const titleInput = await waitForCondition(() => {
return [
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
'.title-input input',
'.title-input textarea',
'[contenteditable="true"][placeholder*="标题"]',
].flatMap(selector => Array.from(document.querySelectorAll(selector))).find(Boolean)
})
if (!titleInput) return { success: false, error: '未找到搜狐号标题输入框' }
if (title) {
setTitleValue(titleInput, title)
await sleep(200)
const receivedTitle = titleInput.isContentEditable ? titleInput.textContent : titleInput.value
if (normalizeText(receivedTitle) !== normalizeText(title)) {
return { success: false, error: '搜狐号未确认接收标题' }
}
}
if (!html) return { success: true, method: 'title-only' }
const ueditor = await waitForCondition(findUeditor, 7000)
if (ueditor) {
try {
ueditor.setContent(html)
ueditor.fireEvent?.('contentchange')
ueditor.fireEvent?.('selectionchange')
await sleep(500)
const serialized = ueditor.getContent?.() || ''
const text = ueditor.getContentTxt?.() || serialized.replace(/<[^>]*>/g, '')
const imageCount = (serialized.match(/<img\b/gi) || []).length
if (hasExpectedContent(text, imageCount)) {
return { success: true, method: 'ueditor', imageCount }
}
} catch {}
}
const iframe = await waitForCondition(findEditorIframe, 7000)
const iframeBody = iframe?.contentDocument?.body
if (iframeBody?.isContentEditable) {
setEditableHtml(iframeBody, html, plainText)
await sleep(500)
const imageCount = iframeBody.querySelectorAll('img').length
if (hasExpectedContent(iframeBody.innerText || iframeBody.textContent, imageCount)) {
return { success: true, method: 'iframe', imageCount }
}
}
const contentEditor = await waitForCondition(() => findContentEditor(titleInput), 7000)
if (contentEditor) {
setEditableHtml(contentEditor, html, plainText)
await sleep(500)
const imageCount = contentEditor.querySelectorAll('img').length
if (hasExpectedContent(contentEditor.innerText || contentEditor.textContent, imageCount)) {
return { success: true, method: 'contenteditable', imageCount }
}
}
return {
success: false,
error: '搜狐号未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return fill()
}
async function inspectSohuTask(tab, helpers) {
const currentState = inspectSohuTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
const context = `${link.textContent || ''} ${link.parentElement?.textContent || ''}`
return url.hostname === 'www.sohu.com'
&& /^\/a\/\d+(?:_\d+)?\/?(?:\.html)?$/.test(url.pathname)
&& /查看文章|阅读文章|预览|发布成功|已发布/.test(context)
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
visibleText,
draftSaved: /草稿(?:已)?保存|已保存草稿|保存草稿成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectSohuTaskState(tab.url || '', observed?.publicUrl, observed?.draftSaved)
if (state.status === 'filled' && /发布成功|已发布/.test(observed?.visibleText || '')) {
return {
...state,
status: 'publication_uncertain',
message: '搜狐号页面显示发布完成,但未找到公开文章地址,请人工核对后回写结果',
}
}
return state
} catch {
return currentState
}
}
async function syncSohuContent(tab, content, helpers) {
if (!tab?.id) return { success: false, message: '无法获取搜狐号编辑器标签页' }
const markdown = content.markdown || content.body || ''
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillSohuContent,
args: [content.title, renderToutiaoMarkdown(markdown), renderToutiaoPlainText(markdown)],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '搜狐号内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充搜狐号编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
export {
fillSohuContent,
inspectSohuTask,
inspectSohuTaskState,
parseSohuPublishedUrl,
SohuPlatform,
syncSohuContent,
}
+62
View File
@@ -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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedRichText = html
? htmlToComparableText(html)
: normalizeComparableText(plainText || contentToFill)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedRichText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedRichText.length / 4)))
const sampleStarts = expectedRichText.length > sampleLength
? [0, Math.floor((expectedRichText.length - sampleLength) / 2), expectedRichText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedRichText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors) {
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
}
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,
}
+493
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeUrl(value) {
try {
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
} catch {
return null
}
}
function renderToutiaoInline(value) {
const pattern = /!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g
let html = ''
let cursor = 0
for (const match of value.matchAll(pattern)) {
html += escapeHtml(value.slice(cursor, match.index))
if (match[1]) {
const url = safeUrl(match[2])
html += url
? `<img src="${escapeHtml(url)}" alt="${escapeHtml(match[1])}" />`
: escapeHtml(match[0])
} else if (match[3]) {
const url = safeUrl(match[4])
html += url
? `<a href="${escapeHtml(url)}">${escapeHtml(match[3])}</a>`
: escapeHtml(match[0])
} else if (match[5]) {
html += `<code>${escapeHtml(match[5])}</code>`
} else if (match[6] || match[7]) {
html += `<strong>${escapeHtml(match[6] || match[7])}</strong>`
} else {
html += `<em>${escapeHtml(match[8] || match[9])}</em>`
}
cursor = match.index + match[0].length
}
return html + escapeHtml(value.slice(cursor))
}
function renderToutiaoMarkdown(markdown) {
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n')
const blocks = []
let paragraph = []
let listType
let listItems = []
let codeLines
const flushParagraph = () => {
if (paragraph.length) {
blocks.push(`<p>${paragraph.map(renderToutiaoInline).join('<br />')}</p>`)
paragraph = []
}
}
const flushList = () => {
if (!listType) return
blocks.push(`<${listType}>${listItems.map(item => `<li>${renderToutiaoInline(item)}</li>`).join('')}</${listType}>`)
listType = undefined
listItems = []
}
const flushBlocks = () => {
flushParagraph()
flushList()
}
for (const line of lines) {
if (codeLines) {
if (/^\s*```/.test(line)) {
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
codeLines = undefined
} else {
codeLines.push(line)
}
continue
}
if (/^\s*```/.test(line)) {
flushBlocks()
codeLines = []
continue
}
if (!line.trim()) {
flushBlocks()
continue
}
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
if (heading) {
flushBlocks()
const level = heading[1].length
blocks.push(`<h${level}>${renderToutiaoInline(heading[2])}</h${level}>`)
continue
}
const unordered = line.match(/^\s*[-*+]\s+(.+)$/)
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/)
if (unordered || ordered) {
flushParagraph()
const nextType = unordered ? 'ul' : 'ol'
if (listType && listType !== nextType) flushList()
listType = nextType
listItems.push((unordered || ordered)[1])
continue
}
const quote = line.match(/^\s*>\s?(.*)$/)
if (quote) {
flushBlocks()
blocks.push(`<blockquote>${renderToutiaoInline(quote[1])}</blockquote>`)
continue
}
if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
flushBlocks()
blocks.push('<hr />')
continue
}
if (listType) flushList()
paragraph.push(line)
}
if (codeLines) blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
flushBlocks()
return blocks.join('')
}
function renderToutiaoPlainText(markdown) {
return String(markdown || '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[`*_#>~-]/g, '')
.replace(/\s+/g, '')
}
function fillToutiaoContent(title, html, plainText) {
const contentHtml = html || ''
const contentText = plainText || contentHtml.replace(/<[^>]+>/g, ' ')
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
const normalizeComparableText = value => String(value || '')
.normalize('NFKC')
.replace(/[^\p{L}\p{N}]+/gu, '')
function htmlToComparableText(value) {
const entities = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
function findFirst(selectors, excludedElement) {
for (const selector of selectors) {
const element = Array.from(document.querySelectorAll(selector))
.find(candidate => (
candidate !== excludedElement
&& !candidate.disabled
&& candidate.offsetParent !== null
))
if (element) return element
}
return null
}
function waitForElement(selectors, excludedElement, timeout = 15000) {
return new Promise(resolve => {
const 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,
}
+276
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
// 使用带样式的 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 = {
'&amp;': '&',
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
return normalizeComparableText(String(value || '')
.replace(/<img\b[^>]*>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|gt|lt|quot|#39|nbsp);/g, entity => entities[entity] || entity))
}
const expectedText = htmlToComparableText(html)
const expectedImageCount = Array.from(String(html || '').matchAll(/<img\b[^>]*>/gi)).length
const minimumTextLength = Math.floor(expectedText.length * 0.8)
const sampleLength = Math.min(24, Math.max(6, Math.floor(expectedText.length / 4)))
const sampleStarts = expectedText.length > sampleLength
? [0, Math.floor((expectedText.length - sampleLength) / 2), expectedText.length - sampleLength]
: [0]
const expectedSamples = [...new Set(sampleStarts.map(start => (
expectedText.slice(start, start + sampleLength)
)).filter(Boolean))]
function findFirst(selectors, exclude) {
return selectors
.flatMap(selector => Array.from(document.querySelectorAll(selector)))
.find(element => element !== exclude)
}
function waitForElement(selectors, exclude, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors, exclude)
if (existing) {
resolve(existing)
return
}
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,
}
+418
View File
@@ -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 }
+13
View File
@@ -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 }
+326
View File
@@ -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,
}
+11
View File
@@ -0,0 +1,11 @@
const RETURNABLE_PLATFORM_TASK_STATUSES = new Set([
'filled',
'draft_saved',
'published',
])
function canReturnExistingPlatformTaskState(state) {
return Boolean(state?.url && RETURNABLE_PLATFORM_TASK_STATUSES.has(state.status))
}
export { canReturnExistingPlatformTaskState }
+70
View File
@@ -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 }
+3
View File
@@ -0,0 +1,3 @@
export * from './src/configs.js'
export * from './src/detect.js'
export * from './src/utils.js'
+25
View File
@@ -0,0 +1,25 @@
/**
* Convert an avatar URL to a base64 data URL to bypass CORS/ORB blocking.
* The extension fetches only the supplied avatar URL and never reads cookies.
*/
export async function convertAvatarToBase64(avatarUrl, referer) {
try {
const imageResponse = await fetch(avatarUrl, {
headers: { Referer: referer },
})
if (!imageResponse.ok) return avatarUrl
const blob = await imageResponse.blob()
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return `data:${blob.type || 'image/jpeg'};base64,${btoa(binary)}`
} catch (error) {
console.log('[COSE] avatar base64 conversion failed:', error.message)
return avatarUrl
}
}
+118
View File
@@ -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,
}
+82
View File
@@ -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(/&nbsp;/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 }
+184
View File
@@ -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 }
}
}
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<title>COSE Offscreen</title>
</head>
<body>
<script src="./offscreen.js" type="module"></script>
</body>
</html>
+171
View File
@@ -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 }
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+51
View File
@@ -0,0 +1,51 @@
(function bridgeAiToEarnInteraction() {
const requestSource = 'aitoearn-interaction-web';
const responseSource = 'aitoearn-interaction-extension';
const allowedMethods = new Set([
'getCapabilities',
'getDouyinAccount',
'listDouyinComments',
'listDouyinReplies',
'replyDouyinComment',
]);
async function handle(method, payload) {
const response = await chrome.runtime.sendMessage({
type: 'AITO_EARN_INTERACTION',
method,
payload,
});
if (!response?.success) {
const error = new Error(response?.error?.message || 'AiToEarn 互动扩展请求失败');
error.code = response?.error?.code || 'INTERACTION_REQUEST_FAILED';
throw error;
}
return response.result;
}
window.addEventListener('message', async (event) => {
if (
event.source !== window
|| event.origin !== window.location.origin
|| event.data?.source !== requestSource
) return;
const { requestId, method, payload } = event.data;
if (!requestId || !allowedMethods.has(method)) return;
try {
const result = await handle(method, payload);
window.postMessage({ source: responseSource, requestId, result }, window.location.origin);
} catch (error) {
window.postMessage({
source: responseSource,
requestId,
error: {
code: error?.code || 'INTERACTION_REQUEST_FAILED',
message: error instanceof Error ? error.message : String(error),
},
}, window.location.origin);
}
});
})();
+72
View File
@@ -0,0 +1,72 @@
(function installAiToEarnInteractionBridge() {
if (window.AIToEarnInteraction) return;
const requestSource = 'aitoearn-interaction-web';
const responseSource = 'aitoearn-interaction-extension';
const pending = new Map();
const defaultTimeoutMs = 60000;
const methodTimeoutMs = Object.freeze({
replyDouyinComment: 120000,
});
window.addEventListener('message', (event) => {
if (
event.source !== window
|| event.origin !== window.location.origin
|| event.data?.source !== responseSource
) return;
const entry = pending.get(event.data.requestId);
if (!entry) return;
pending.delete(event.data.requestId);
if (event.data.error) {
const error = new Error(event.data.error.message || 'AiToEarn 互动扩展请求失败');
error.code = event.data.error.code || 'INTERACTION_REQUEST_FAILED';
entry.reject(error);
return;
}
entry.resolve(event.data.result);
});
function request(method, payload) {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
const timeoutMs = methodTimeoutMs[method] || defaultTimeoutMs;
const timeout = window.setTimeout(() => {
pending.delete(requestId);
const error = new Error('AiToEarn 互动扩展响应超时');
error.code = 'INTERACTION_RESPONSE_TIMEOUT';
reject(error);
}, timeoutMs);
pending.set(requestId, {
resolve(value) {
window.clearTimeout(timeout);
resolve(value);
},
reject(error) {
window.clearTimeout(timeout);
reject(error);
},
});
window.postMessage({ source: requestSource, requestId, method, payload }, window.location.origin);
});
}
Object.defineProperty(window, 'AIToEarnInteraction', {
configurable: false,
enumerable: false,
writable: false,
value: {
getCapabilities: () => request('getCapabilities'),
getDouyinAccount: () => request('getDouyinAccount'),
listDouyinComments: payload => request('listDouyinComments', payload),
listDouyinReplies: payload => request('listDouyinReplies', payload),
replyDouyinComment: payload => request('replyDouyinComment', payload),
},
});
window.dispatchEvent(new CustomEvent('aitoearn:interaction-ready'));
})();
+238
View File
@@ -0,0 +1,238 @@
import {
doesDouyinAccountMatch,
executeDouyinCommentRequest,
executeDouyinReplyRequest,
executeDouyinSessionRequest,
InteractionValidationError,
normalizeDouyinRequest,
} from './douyin.js';
import { createInteractionCapabilities } from './capabilities.js';
const MESSAGE_TYPE = 'AITO_EARN_INTERACTION';
const ALLOWED_APP_ORIGINS = new Set([
'http://localhost:6061',
'http://127.0.0.1:6061',
'https://wx.frp.it1024.cc',
]);
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
function createError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function isAllowedSender(sender) {
const senderUrl = sender?.url || sender?.tab?.url;
if (!senderUrl) return false;
try {
return ALLOWED_APP_ORIGINS.has(new URL(senderUrl).origin);
} catch {
return false;
}
}
async function waitForTabReady(tabId) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const tab = await chrome.tabs.get(tabId);
if (tab.status === 'complete') return tab;
await sleep(250);
}
throw createError('DOUYIN_PAGE_TIMEOUT', '抖音页面加载超时');
}
async function acquireDouyinTab(workId) {
const workUrl = `https://www.douyin.com/video/${workId}`;
const tabs = await chrome.tabs.query({ url: ['https://www.douyin.com/*'] });
let tab = tabs.find(candidate => candidate.id && candidate.url?.startsWith(workUrl));
if (!tab) tab = tabs.find(candidate => candidate.id && !candidate.discarded);
const created = !tab;
if (!tab) tab = await chrome.tabs.create({ url: workUrl, active: false });
if (!tab.id) throw createError('DOUYIN_TAB_UNAVAILABLE', '无法创建或复用抖音标签页');
await waitForTabReady(tab.id);
return { tabId: tab.id, created };
}
async function acquireDouyinCreatorTab(allowCreate) {
const tabs = await chrome.tabs.query({ url: ['https://creator.douyin.com/*'] });
let tab = tabs.find(candidate => candidate.id && !candidate.discarded);
const created = !tab;
if (!tab && !allowCreate) {
throw createError(
'DOUYIN_CREATOR_TAB_REQUIRED',
'请先在当前浏览器打开并登录抖音创作者中心,再刷新互动管理',
);
}
if (!tab) tab = await chrome.tabs.create({ url: 'https://creator.douyin.com/', active: false });
if (!tab.id) throw createError('DOUYIN_CREATOR_TAB_UNAVAILABLE', '无法创建或复用抖音创作者中心标签页');
await waitForTabReady(tab.id);
return { tabId: tab.id, created };
}
async function readDouyinSession(includeCsrf = false, allowCreate = false) {
const tab = await acquireDouyinCreatorTab(allowCreate);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinSessionRequest,
args: [{ includeCsrf }],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_ACCOUNT_REQUEST_FAILED';
const message = response?.error?.message || '抖音账号读取失败';
if (code === 'DOUYIN_LOGIN_REQUIRED') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return response.result;
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
async function listDouyinComments(method, payload) {
const request = normalizeDouyinRequest(method, payload);
const tab = await acquireDouyinTab(request.workId);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinCommentRequest,
args: [{
...request,
kind: method === 'listDouyinReplies' ? 'replies' : 'comments',
}],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_REQUEST_FAILED';
const message = response?.error?.message || '抖音评论读取失败';
if (code === 'DOUYIN_LOGIN_REQUIRED') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return {
...response.result,
reusedTab: !tab.created,
};
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
async function replyDouyinComment(payload) {
const request = normalizeDouyinRequest('replyDouyinComment', payload);
const session = await readDouyinSession(true, true);
if (!doesDouyinAccountMatch(request.expectedAccountUid, session.account)) {
throw createError(
'DOUYIN_ACCOUNT_MISMATCH',
`当前浏览器登录的是“${session.account.nickname || session.account.uid}”,与所选 AiToEarn 账号不一致`,
);
}
const tab = await acquireDouyinTab(request.workId);
let keepTabOpen = false;
try {
const execution = await chrome.scripting.executeScript({
target: { tabId: tab.tabId },
world: 'MAIN',
func: executeDouyinReplyRequest,
args: [{ ...request, csrfToken: session.csrfToken }],
});
const response = execution?.[0]?.result;
if (!response?.ok) {
const code = response?.error?.code || 'DOUYIN_REPLY_REQUEST_FAILED';
const message = response?.error?.message || '抖音评论回复失败';
if (code === 'DOUYIN_LOGIN_REQUIRED' || code === 'DOUYIN_RISK_CONTROL') {
keepTabOpen = true;
await chrome.tabs.update(tab.tabId, { active: true });
}
throw createError(code, message);
}
return {
...response.result,
account: session.account,
reusedTab: !tab.created,
};
} finally {
if (tab.created && !keepTabOpen) {
await chrome.tabs.remove(tab.tabId).catch(() => undefined);
}
}
}
function getCapabilities() {
return createInteractionCapabilities(chrome.runtime.getManifest().version);
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request?.type !== MESSAGE_TYPE) return false;
if (!isAllowedSender(sender)) {
sendResponse({
success: false,
error: { code: 'INTERACTION_ORIGIN_DENIED', message: '当前页面无权调用互动扩展' },
});
return false;
}
const method = request.method;
if (![
'getCapabilities',
'getDouyinAccount',
'listDouyinComments',
'listDouyinReplies',
'replyDouyinComment',
].includes(method)) {
sendResponse({
success: false,
error: { code: 'INTERACTION_METHOD_DENIED', message: '不支持的互动扩展方法' },
});
return false;
}
Promise.resolve(
method === 'getCapabilities'
? getCapabilities()
: method === 'getDouyinAccount'
? readDouyinSession(false, false).then(session => session.account)
: method === 'replyDouyinComment'
? replyDouyinComment(request.payload)
: listDouyinComments(method, request.payload),
)
.then(result => sendResponse({ success: true, result }))
.catch((error) => {
sendResponse({
success: false,
error: {
code: error?.code || (error instanceof InteractionValidationError
? 'INVALID_INTERACTION_PAYLOAD'
: 'INTERACTION_REQUEST_FAILED'),
message: error instanceof Error ? error.message : String(error),
},
});
});
return true;
});
+16
View File
@@ -0,0 +1,16 @@
export function createInteractionCapabilities(version) {
return {
version,
source: 'aitoearn-interaction',
platforms: {
douyin: {
comments: {
list: true,
replies: true,
create: false,
manualReply: true,
},
},
},
};
}
+430
View File
@@ -0,0 +1,430 @@
const ID_PATTERN = /^\d{1,32}$/;
const CURSOR_PATTERN = /^\d{1,16}$/;
const DEFAULT_COUNT = 20;
const MAX_COUNT = 20;
const MAX_REPLY_LENGTH = 500;
export class InteractionValidationError extends Error {
constructor(code, message) {
super(message);
this.name = 'InteractionValidationError';
this.code = code;
}
}
function normalizeId(value, fieldName) {
const id = typeof value === 'number' ? String(value) : String(value || '').trim();
if (!ID_PATTERN.test(id)) {
throw new InteractionValidationError('INVALID_DOUYIN_ID', `${fieldName} 必须是纯数字 ID`);
}
return id;
}
function normalizeCursor(value) {
if (value === undefined || value === null || value === '') return '0';
const cursor = typeof value === 'number' ? String(value) : String(value).trim();
if (!CURSOR_PATTERN.test(cursor)) {
throw new InteractionValidationError('INVALID_DOUYIN_CURSOR', '分页游标必须是非负整数');
}
return cursor;
}
function normalizeCount(value) {
if (value === undefined || value === null || value === '') return DEFAULT_COUNT;
const count = Number(value);
if (!Number.isInteger(count) || count < 1) {
throw new InteractionValidationError('INVALID_DOUYIN_COUNT', '每页数量必须是正整数');
}
return Math.min(count, MAX_COUNT);
}
function normalizeReplyContent(value) {
const content = typeof value === 'string' ? value.trim() : '';
if (!content) {
throw new InteractionValidationError('EMPTY_DOUYIN_REPLY', '回复内容不能为空');
}
if (content.length > MAX_REPLY_LENGTH) {
throw new InteractionValidationError(
'DOUYIN_REPLY_TOO_LONG',
`回复内容不能超过 ${MAX_REPLY_LENGTH} 个字符`,
);
}
return content;
}
function normalizeExpectedAccountUid(value) {
const uid = typeof value === 'string' ? value.trim() : '';
if (!uid || uid.length > 256 || /[\u0000-\u001f\u007f]/.test(uid)) {
throw new InteractionValidationError('INVALID_DOUYIN_ACCOUNT_UID', '缺少有效的抖音账号 UID');
}
return uid;
}
export function doesDouyinAccountMatch(expectedAccountUid, account) {
const expected = typeof expectedAccountUid === 'string' ? expectedAccountUid.trim() : '';
if (!expected || !account || typeof account !== 'object') return false;
return [account.uid, account.numericUid, account.uniqueId]
.some(value => typeof value === 'string' && value === expected);
}
export async function executeDouyinSessionRequest(options = {}) {
function getAvatarUrl(user) {
return [
...(user?.avatar_thumb?.url_list || []),
...(user?.avatar_medium?.url_list || []),
].find(url => typeof url === 'string' && url.startsWith('https://')) || '';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const userResponse = await fetch('/web/api/media/user/info/', {
credentials: 'include',
headers: { accept: 'application/json, text/plain, */*' },
signal: controller.signal,
});
if (!userResponse.ok) {
return {
ok: false,
error: {
code: userResponse.status === 401 || userResponse.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_ACCOUNT_HTTP_ERROR',
message: `抖音账号接口返回 HTTP ${userResponse.status}`,
},
};
}
const data = await userResponse.json();
const user = data?.user || data?.user_info;
if (Number(data?.status_code) !== 0 || !user) {
return {
ok: false,
error: {
code: 'DOUYIN_LOGIN_REQUIRED',
message: data?.status_msg || '当前浏览器尚未登录抖音创作者中心',
},
};
}
const account = {
uid: String(user.sec_uid || user.uid || ''),
numericUid: String(user.uid || ''),
uniqueId: String(user.unique_id || ''),
nickname: typeof user.nickname === 'string' ? user.nickname : '',
avatarUrl: getAvatarUrl(user),
};
if (!account.uid) {
return {
ok: false,
error: {
code: 'DOUYIN_ACCOUNT_ID_MISSING',
message: '抖音账号接口未返回可核对的账号 UID',
},
};
}
if (!options.includeCsrf) {
return { ok: true, result: { account } };
}
const csrfResponse = await fetch('/web/api/media/anchor/search', {
method: 'HEAD',
credentials: 'include',
headers: {
accept: '*/*',
'X-Secsdk-Csrf-Request': '1',
'X-Secsdk-Csrf-Version': '1.2.22',
},
signal: controller.signal,
});
const csrfHeader = csrfResponse.headers.get('x-ware-csrf-token') || '';
const csrfParts = csrfHeader.split(',').map(value => value.trim()).filter(Boolean);
const csrfToken = csrfParts[1] || csrfParts[0] || '';
if (!csrfResponse.ok || !csrfToken) {
return {
ok: false,
error: {
code: 'DOUYIN_CSRF_TOKEN_FAILED',
message: '无法获取抖音回复所需的安全令牌,请刷新创作者中心后重试',
},
};
}
return { ok: true, result: { account, csrfToken } };
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError'
? 'DOUYIN_ACCOUNT_REQUEST_TIMEOUT'
: 'DOUYIN_ACCOUNT_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音账号读取超时,请刷新创作者中心后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
export async function executeDouyinReplyRequest(request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const body = new FormData();
body.set('aweme_id', request.workId);
body.set('reply_id', request.commentId);
body.set('text', request.content);
body.set('one_level_comment_rank', '1');
const response = await fetch('/aweme/v1/web/comment/publish/?aid=6383', {
method: 'POST',
credentials: 'include',
headers: {
accept: 'application/json, text/plain, */*',
'x-secsdk-csrf-token': request.csrfToken,
},
body,
signal: controller.signal,
});
if (!response.ok) {
return {
ok: false,
error: {
code: response.status === 401 || response.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_REPLY_HTTP_ERROR',
message: `抖音回复接口返回 HTTP ${response.status}`,
},
};
}
const data = await response.json();
if (Number(data?.status_code) !== 0) {
const message = data?.status_msg || `抖音回复接口返回状态 ${data?.status_code}`;
return {
ok: false,
error: {
code: /验证|频繁|风险|安全/.test(message)
? 'DOUYIN_RISK_CONTROL'
: /登录/.test(message)
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_REPLY_API_ERROR',
message,
},
};
}
return {
ok: true,
result: {
platform: 'douyin',
workId: request.workId,
parentCommentId: request.commentId,
platformCommentId: String(data?.comment?.cid || data?.cid || ''),
success: true,
source: 'douyin-web',
},
};
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError' ? 'DOUYIN_REPLY_TIMEOUT' : 'DOUYIN_REPLY_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音回复请求超时,请确认页面可正常访问后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
export function normalizeDouyinRequest(method, payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new InteractionValidationError('INVALID_INTERACTION_PAYLOAD', '互动请求参数格式不正确');
}
const workId = normalizeId(payload.workId, '作品 ID');
if (method === 'replyDouyinComment') {
return {
workId,
commentId: normalizeId(payload.commentId, '评论 ID'),
content: normalizeReplyContent(payload.content),
expectedAccountUid: normalizeExpectedAccountUid(payload.expectedAccountUid),
};
}
const normalized = {
workId,
cursor: normalizeCursor(payload.cursor),
count: normalizeCount(payload.count),
};
if (method === 'listDouyinReplies') {
normalized.commentId = normalizeId(payload.commentId, '评论 ID');
}
return normalized;
}
export async function executeDouyinCommentRequest(request) {
function getAvatarUrl(user) {
const candidates = [
...(user?.avatar_thumb?.url_list || []),
...(user?.avatar_medium?.url_list || []),
];
return candidates.find(url => typeof url === 'string' && url.startsWith('https://')) || '';
}
function toFiniteNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function normalizeComment(comment, parentCommentId) {
const id = String(comment?.cid || '');
const nestedReplies = Array.isArray(comment?.reply_comment)
? comment.reply_comment.map(reply => normalizeComment(reply, id)).filter(reply => reply.id)
: [];
const createdAtSeconds = toFiniteNumber(comment?.create_time);
return {
id,
workId: String(comment?.aweme_id || request.workId),
parentCommentId: parentCommentId || undefined,
rootCommentId: String(parentCommentId || comment?.root_comment_id || id || ''),
text: typeof comment?.text === 'string' ? comment.text : '',
createdAt: createdAtSeconds > 0 ? new Date(createdAtSeconds * 1000).toISOString() : undefined,
likeCount: toFiniteNumber(comment?.digg_count),
replyCount: toFiniteNumber(
parentCommentId
? comment?.reply_comment_total
: comment?.reply_comment_total ?? comment?.comment_reply_total,
nestedReplies.length,
),
ipLabel: typeof comment?.ip_label === 'string' ? comment.ip_label : '',
isAuthor: comment?.label_text === '作者' || Number(comment?.label_type) === 1,
author: {
name: typeof comment?.user?.nickname === 'string' ? comment.user.nickname : '',
uid: String(comment?.user?.uid || ''),
secUid: String(comment?.user?.sec_uid || ''),
avatarUrl: getAvatarUrl(comment?.user),
},
replies: nestedReplies,
};
}
function getBrowserVersion() {
const match = navigator.userAgent.match(/(?:Chrome|Edg)\/(\d+(?:\.\d+){0,3})/);
return match?.[1] || '';
}
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const commonParams = {
device_platform: 'webapp',
aid: '6383',
channel: 'channel_pc_web',
item_type: '0',
cut_version: '1',
update_version_code: '170400',
pc_client_type: '1',
pc_libra_divert: 'Windows',
support_h265: '1',
support_dash: '1',
cpu_core_num: String(navigator.hardwareConcurrency || 8),
version_code: '170400',
version_name: '17.4.0',
cookie_enabled: String(navigator.cookieEnabled),
screen_width: String(screen.width || 1920),
screen_height: String(screen.height || 1080),
browser_language: navigator.language || 'zh-CN',
browser_platform: navigator.platform || 'Win32',
browser_name: navigator.userAgent.includes('Edg/') ? 'Edge' : 'Chrome',
browser_version: getBrowserVersion(),
browser_online: String(navigator.onLine),
engine_name: 'Blink',
os_name: 'Windows',
device_memory: String(navigator.deviceMemory || 8),
platform: 'PC',
downlink: String(connection?.downlink || 10),
effective_type: connection?.effectiveType || '4g',
round_trip_time: String(connection?.rtt || 50),
};
const isReplies = request.kind === 'replies';
const params = new URLSearchParams({
...commonParams,
cursor: request.cursor,
count: String(request.count),
...(isReplies
? { item_id: request.workId, comment_id: request.commentId }
: { aweme_id: request.workId }),
});
const endpoint = isReplies
? '/aweme/v1/web/comment/list/reply/'
: '/aweme/v1/web/comment/list/';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
const response = await fetch(`${endpoint}?${params}`, {
credentials: 'include',
headers: { accept: 'application/json, text/plain, */*' },
signal: controller.signal,
});
if (!response.ok) {
return {
ok: false,
error: {
code: response.status === 401 || response.status === 403
? 'DOUYIN_LOGIN_REQUIRED'
: 'DOUYIN_HTTP_ERROR',
message: `抖音评论接口返回 HTTP ${response.status}`,
},
};
}
const data = await response.json();
if (Number(data?.status_code) !== 0) {
return {
ok: false,
error: {
code: 'DOUYIN_API_ERROR',
message: data?.status_msg || `抖音评论接口返回状态 ${data?.status_code}`,
},
};
}
return {
ok: true,
result: {
platform: 'douyin',
workId: request.workId,
parentCommentId: isReplies ? request.commentId : undefined,
items: (Array.isArray(data.comments) ? data.comments : [])
.map(comment => normalizeComment(comment, isReplies ? request.commentId : undefined))
.filter(comment => comment.id),
cursor: String(data.cursor ?? ''),
hasMore: Boolean(data.has_more),
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
source: 'douyin-web',
},
};
} catch (error) {
return {
ok: false,
error: {
code: error?.name === 'AbortError' ? 'DOUYIN_REQUEST_TIMEOUT' : 'DOUYIN_REQUEST_FAILED',
message: error?.name === 'AbortError'
? '抖音评论请求超时,请确认页面可以正常访问后重试'
: (error instanceof Error ? error.message : String(error)),
},
};
} finally {
clearTimeout(timeout);
}
}
+84 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "aitoearn-chrome-extension",
"private": true,
"type": "module",
"scripts": {
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs && node scripts/test-browser-agent.mjs",
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1",
"release:verify": "node scripts/verify-release.mjs"
}
}
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
+17
View File
@@ -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
View File
@@ -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">&times;</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