Compare commits

...

7 Commits

97 changed files with 12515 additions and 702 deletions
+3
View File
@@ -0,0 +1,3 @@
dist/
aitoearn-extension.zip
node_modules/
+61
View File
@@ -0,0 +1,61 @@
# 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 信息上传到服务端。当前已接入掘金、简书、知乎和今日头条:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条会将 Markdown 转为安全的富文本粘贴,保留标题、段落、列表、链接和图片。
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id``www.toutiao.com/item/:id` 才记为已发布;知乎 `/write/:id` 和今日头条带 `draft_id` 的编辑器地址记为草稿,无法确定时进入“发布结果待确认”,不会伪报成功。
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
扩展弹窗和后台通知中的“成功”只表示平台编辑器已打开并完成内容填充,不表示内容已经公开发布。右键菜单“提取此页面到 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 语法和必需文件,再输出:
```text
dist/aitoearn-extension-v<version>.zip
```
ZIP 根目录直接包含 `manifest.json`,可以解压后加载。
Binary file not shown.
+222 -29
View File
@@ -1,38 +1,231 @@
// AiToEarn Background Service Worker
import './distribution/cose/background.js';
import './interaction/background.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 platformIds = Array.isArray(request.platformIds)
? request.platformIds.filter(platformId => typeof platformId === 'string' && platformId.trim())
: [];
if (platformIds.length === 0 || !request.content || typeof request.content !== 'object') {
sendResponse({ success: false, error: '缺少目标平台或分发内容' });
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);
+112
View File
@@ -0,0 +1,112 @@
(function bridgeAiToEarnDistribution() {
const requestSource = 'aitoearn-web';
const responseSource = 'aitoearn-extension';
const allowedMethods = new Set([
'getVersion',
'listPlatforms',
'checkPlatforms',
'getPlatformAccount',
'captureUrl',
'startBatch',
'syncToPlatform',
'openPlatformTask',
'inspectPlatformTask',
]);
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,
});
}
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),
}, '*');
}
});
})();
+66
View File
@@ -0,0 +1,66 @@
(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,
}),
},
});
window.dispatchEvent(new CustomEvent('aitoearn:distribution-ready'));
})();
+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,109 @@
// 支付宝开放平台配置
const AlipayOpenPlatform = {
id: 'alipayopen',
name: 'AlipayOpen',
icon: 'https://www.alipay.com/favicon.ico',
url: 'https://open.alipay.com',
publishUrl: 'https://open.alipay.com/portal/forum/post/add#article',
title: '支付宝开放平台',
type: 'alipayopen',
}
/**
* 支付宝开放平台内容填充函数
* 注意:此函数会被序列化后通过 chrome.scripting.executeScript 注入页面执行
* 因此必须是自包含的,不能依赖外部模块或闭包
* @param {string} title - 文章标题
* @param {string} markdown - Markdown 内容
*/
function fillAlipayOpenContent(title, markdown) {
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
return (async () => {
try {
console.log('[COSE] 支付宝开放平台 开始填充, 标题:', title)
// 等待页面加载
await sleep(500)
// 填充标题 - 尝试多种选择器
let titleInput = document.querySelector('input[placeholder*="标题"]')
if (!titleInput) {
titleInput = document.querySelector('input[placeholder*="请输入"]')
}
if (!titleInput) {
const allInputs = document.querySelectorAll('input')
for (const inp of allInputs) {
if (inp.placeholder && inp.placeholder.includes('标题')) {
titleInput = inp
break
}
}
}
console.log('[COSE] 支付宝开放平台 查找标题输入框:', !!titleInput)
if (titleInput && title) {
titleInput.focus()
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }))
console.log('[COSE] 支付宝开放平台 标题填充成功:', title)
} else {
console.log('[COSE] 支付宝开放平台 标题填充失败 - input:', !!titleInput, 'title:', !!title)
}
await sleep(300)
// 填充内容 - 支付宝开放平台使用 ne-engine 富文本编辑器
const editor = document.querySelector('.ne-engine')
if (editor && markdown) {
editor.focus()
await sleep(100)
// 使用 ClipboardEvent 模拟粘贴 Markdown 内容
const dt = new DataTransfer()
dt.setData('text/plain', markdown)
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt,
})
editor.dispatchEvent(pasteEvent)
console.log('[COSE] 支付宝开放平台 内容粘贴成功')
// 等待并点击"立即转换"按钮
let confirmed = false
for (let i = 0; i < 15; i++) {
await sleep(200)
const convertBtn = Array.from(document.querySelectorAll('button')).find(btn =>
btn.textContent.includes('立即转换')
)
if (convertBtn) {
convertBtn.click()
confirmed = true
console.log('[COSE] 支付宝开放平台 Markdown 转换成功')
break
}
}
return { success: true, confirmed }
}
return { success: false, error: '未找到编辑器' }
} catch (e) {
console.error('[COSE] 支付宝开放平台 填充失败:', e)
return { success: false, error: e.message }
}
})()
}
// 导出
export { AlipayOpenPlatform, fillAlipayOpenContent }
@@ -0,0 +1,53 @@
// 阿里云开发者社区平台配置
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',
}
// 阿里云开发者社区内容填充函数
async function fillAliyunContent(content) {
const { title, markdown } = content
console.log('[COSE] 阿里云开发者社区:开始填充内容')
// 等待页面加载
await new Promise(resolve => setTimeout(resolve, 2000))
// 填充标题
const titleInput = document.querySelector('input[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 阿里云开发者社区:标题已填充')
}
// 等待一下再填充正文
await new Promise(resolve => setTimeout(resolve, 500))
// 填充正文(markdown 编辑器)
// 阿里云开发者社区使用的是 markdown 编辑器,textarea 是主要输入区域
const contentTextarea =
document.querySelector('textarea[class*="editor"]') ||
document.querySelector('.markdown-editor textarea') ||
document.querySelector('textarea')
if (contentTextarea && markdown) {
contentTextarea.focus()
contentTextarea.value = markdown
contentTextarea.dispatchEvent(new Event('input', { bubbles: true }))
contentTextarea.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 阿里云开发者社区:正文已填充')
}
console.log('[COSE] 阿里云开发者社区:内容填充完成')
}
// 导出
export { AliyunPlatform, fillAliyunContent }
@@ -0,0 +1,94 @@
// 百家号平台配置
const BaijiahaoPlat = {
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',
}
// 百家号内容填充函数
async function fillBaijiahaoContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
// 1. 填充标题
// 百家号标题输入框在 .client_components_titleInput 内的 contenteditable div
await new Promise(resolve => setTimeout(resolve, 1000))
const titleEditor =
document.querySelector('.client_components_titleInput [contenteditable="true"]') ||
document.querySelector('.client_pages_edit_components_titleInput [contenteditable="true"]') ||
document.querySelector('[class*="titleInput"] [contenteditable="true"]')
if (titleEditor) {
titleEditor.focus()
// 清空现有内容
titleEditor.innerHTML = ''
// 使用 document.execCommand 插入文本
document.execCommand('insertText', false, title)
// 如果 execCommand 不生效,使用备用方案
if (!titleEditor.textContent) {
titleEditor.innerHTML = `<p dir="auto">${title}</p>`
}
titleEditor.dispatchEvent(new Event('input', { bubbles: true }))
titleEditor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 百家号标题填充成功')
} else {
console.log('[COSE] 百家号未找到标题输入框')
}
// 2. 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1500))
// 3. 填充正文内容
// 百家号使用 UEditor,内容在 iframe 中
const iframe = document.querySelector('iframe')
if (iframe && iframe.contentDocument) {
const iframeBody = iframe.contentDocument.body
if (iframeBody && iframeBody.contentEditable === 'true') {
iframeBody.focus()
// 将 markdown 转换为简单的 HTML 段落
const htmlContent = contentToFill
.split('\n\n')
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
iframeBody.innerHTML = htmlContent
iframeBody.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 百家号 iframe 编辑器填充成功')
return
}
}
// 尝试通过 UEditor API 填充
if (window.UE_V2 && window.UE_V2.instants && window.UE_V2.instants.ueditorInstant0) {
try {
const editor = window.UE_V2.instants.ueditorInstant0
const htmlContent = contentToFill
.split('\n\n')
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
editor.setContent(htmlContent)
console.log('[COSE] 百家号通过 UEditor API 填充成功')
return
} catch (e) {
console.log('[COSE] 百家号 UEditor API 调用失败', e)
}
}
// 降级:尝试直接操作 contenteditable
const contentEditor = document.querySelector('[contenteditable="true"]:not([class*="title"])')
if (contentEditor) {
contentEditor.focus()
contentEditor.innerHTML = contentToFill.replace(/\n/g, '<br>')
contentEditor.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 百家号 contenteditable 降级填充成功')
} else {
console.log('[COSE] 百家号未找到编辑器元素')
}
}
// 导出
export { BaijiahaoPlat as BaijiahaoPlatform, fillBaijiahaoContent }
@@ -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'
+97
View File
@@ -0,0 +1,97 @@
// CSDN 平台配置
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',
}
import { injectUtils } from './common.js'
// CSDN 内容填充函数(在页面主世界中执行)
// 此函数会被序列化后注入到页面中执行
// 注意:需要先调用 injectUtils 注入 window.waitFor
function fillCSDNContent(title, markdown, body) {
const contentToFill = markdown || body || ''
async function fill() {
// 填充标题(使用注入的 window.waitFor
const titleInput = await window.waitFor('.article-bar__title input, input[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// CSDN 使用 contenteditable 的 PRE 元素
const editor = document.querySelector(
'.editor__inner[contenteditable="true"], [contenteditable="true"].markdown-highlighting'
)
if (editor) {
editor.focus()
// 清空现有内容
editor.textContent = ''
// 直接设置文本内容
editor.textContent = contentToFill
// 触发 input 事件让编辑器识别变化
editor.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] CSDN contenteditable 填充成功')
return { success: true, method: 'contenteditable' }
} else {
// 降级尝试其他方式
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] CSDN CodeMirror 填充成功')
return { success: true, method: 'CodeMirror' }
} else {
console.log('[COSE] CSDN 未找到编辑器元素')
return { success: false, error: 'Editor not found' }
}
}
}
return fill()
}
/**
* CSDN 同步处理器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncCSDNContent(tab, content, helpers) {
const { chrome } = helpers
// 等待页面加载
await new Promise(resolve => setTimeout(resolve, 2000))
// 先注入公共工具函数(waitFor, setInputValue
await injectUtils(chrome, tab.id)
// 在页面中执行填充脚本
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillCSDNContent,
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已同步到 CSDN', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
}
}
// 导出
export { CSDNPlatform, fillCSDNContent, syncCSDNContent }
+72
View File
@@ -0,0 +1,72 @@
// 51CTO 平台配置
const CTO51Platform = {
id: 'cto51',
name: '51CTO',
icon: 'https://blog.51cto.com/favicon.ico',
url: 'https://blog.51cto.com',
loginUrl: 'https://home.51cto.com/index/login',
publishUrl: 'https://blog.51cto.com/blogger/publish',
title: '51CTO',
type: 'cto51',
}
// 51CTO 内容填充函数
async function fillCTO51Content(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
// 1. 填充标题
// 51CTO 标题输入框通常是 input#title 或 placeholder="请输入标题"
const titleInput = await waitFor('#title, input[placeholder*="标题"]')
if (titleInput) {
setInputValue(titleInput, title)
console.log('[COSE] 51CTO 标题填充成功')
}
// 2. 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 2000))
// 3. 填充内容
// 51CTO 有 Markdown 编辑器和富文本编辑器,通常默认 Markdown
// 尝试寻找 Markdown 编辑器的 textarea 或 CodeMirror
const editor =
document.querySelector('.editormd-markdown-textarea') || // Editor.md
document.querySelector('#my-editormd-markdown-doc') || // 常见 ID
document.querySelector('.CodeMirror textarea') || // CodeMirror 核心
document.querySelector('textarea[name="content"]') // 通用 fallback
if (editor) {
// 如果是 CodeMirror,通常需要操作 DOM 或使用 setValue
// 尝试直接设置 value 并触发事件
editor.focus()
editor.value = contentToFill
editor.dispatchEvent(new Event('input', { bubbles: true }))
editor.dispatchEvent(new Event('change', { bubbles: true }))
// 如果页面上有 editor.md 的全局实例,尝试调用
// 这需要在 page context 执行,目前 fillContentOnPage 是在 Main world 执行的,所以可以访问 window
if (window.editor) {
try {
window.editor.setMarkdown(contentToFill)
console.log('[COSE] 51CTO 通过 window.editor 设置成功')
return
} catch (e) {
console.log('[COSE] 51CTO window.editor 调用失败', e)
}
}
console.log('[COSE] 51CTO textarea 填充尝试完成')
} else {
console.log('[COSE] 51CTO 未找到编辑器元素,尝试降级 contenteditable')
// 可能是富文本模式的 contenteditable
const contentEditable = document.querySelector('[contenteditable="true"]')
if (contentEditable) {
contentEditable.innerHTML = contentToFill.replace(/\n/g, '<br>')
console.log('[COSE] 51CTO contenteditable 填充成功')
}
}
}
// 导出
export { CTO51Platform, fillCTO51Content }
@@ -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 }
+142
View File
@@ -0,0 +1,142 @@
// 平台配置汇总
// 从 @cose/detection 导入登录检测配置
import { LOGIN_CHECK_CONFIG } from '../../detection/index.js'
// 平台元数据和同步函数从各平台文件导入
import { CSDNPlatform, 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 { SegmentFaultPlatform } from './segmentfault.js'
import { CnblogsPlatform } from './cnblogs.js'
import { OSChinaPlatform } from './oschina.js'
import { CTO51Platform } from './cto51.js'
import { InfoQPlatform } from './infoq.js'
import {
inspectJianshuTask,
JianshuPlatform,
syncJianshuContent,
} from './jianshu.js'
import { BaijiahaoPlatform } from './baijiahao.js'
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
import { TencentCloudPlatform } from './tencentcloud.js'
import { MediumPlatform } from './medium.js'
import { SspaiPlatform } from './sspai.js'
import { SohuPlatform } from './sohu.js'
import { BilibiliPlatform } from './bilibili.js'
import { WeiboPlatform } from './weibo.js'
import { AliyunPlatform } 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 PLATFORMS = [
CSDNPlatform,
JuejinPlatform,
WechatPlatform,
ZhihuPlatform,
ToutiaoPlatform,
SegmentFaultPlatform,
CnblogsPlatform,
OSChinaPlatform,
CTO51Platform,
InfoQPlatform,
JianshuPlatform,
BaijiahaoPlatform,
WangyihaoPlatform,
TencentCloudPlatform,
MediumPlatform,
SspaiPlatform,
SohuPlatform,
BilibiliPlatform,
WeiboPlatform,
AliyunPlatform,
HuaweiCloudPlatform,
HuaweiDevPlatform,
TwitterPlatform,
QianfanPlatform,
AlipayOpenPlatform,
ModelScopePlatform,
VolcenginePlatform,
DouyinPlatform,
XiaohongshuPlatform,
ElecfansPlatform,
DoubanPlatform,
]
// 根据 hostname 获取平台填充函数
function getPlatformFiller(hostname) {
if (hostname.includes('csdn.net')) return 'csdn'
if (hostname.includes('juejin.cn')) return 'juejin'
if (hostname.includes('mp.weixin.qq.com')) return 'wechat'
if (hostname.includes('zhihu.com')) return 'zhihu'
if (hostname.includes('toutiao.com')) return 'toutiao'
if (hostname.includes('segmentfault.com')) return 'segmentfault'
if (hostname.includes('cnblogs.com')) return 'cnblogs'
if (hostname.includes('oschina.net')) return 'oschina'
if (hostname.includes('51cto.com')) return 'cto51'
if (hostname.includes('infoq.cn')) return 'infoq'
if (hostname.includes('jianshu.com')) return 'jianshu'
if (hostname.includes('baijiahao.baidu.com')) return 'baijiahao'
if (hostname.includes('mp.163.com')) return 'wangyihao'
if (hostname.includes('cloud.tencent.com')) return 'tencentcloud'
if (hostname.includes('medium.com')) return 'medium'
if (hostname.includes('sspai.com')) return 'sspai'
if (hostname.includes('mp.sohu.com')) return 'sohu'
if (hostname.includes('member.bilibili.com')) return 'bilibili'
if (hostname.includes('card.weibo.com')) return 'weibo'
if (hostname.includes('developer.aliyun.com')) return 'aliyun'
if (hostname.includes('bbs.huaweicloud.com')) return 'huaweicloud'
if (hostname.includes('developer.huawei.com')) return 'huaweidev'
if (hostname.includes('x.com') || hostname.includes('twitter.com')) return 'twitter'
if (hostname.includes('qianfan.cloud.baidu.com')) return 'qianfan'
if (hostname.includes('open.alipay.com')) return 'alipayopen'
if (hostname.includes('modelscope.cn')) return 'modelscope'
if (hostname.includes('developer.volcengine.com')) return 'volcengine'
if (hostname.includes('creator.douyin.com')) return 'douyin'
if (hostname.includes('creator.xiaohongshu.com')) return 'xiaohongshu'
if (hostname.includes('elecfans.com')) return 'elecfans'
if (hostname.includes('douban.com')) return 'douban'
return 'generic'
}
// 同步处理器映射
// 如果平台有自定义同步逻辑,在此注册处理器
// 未注册的平台将使用 background.js 中的通用填充逻辑
const SYNC_HANDLERS = {
csdn: syncCSDNContent,
juejin: syncJuejinContent,
jianshu: syncJianshuContent,
wechat: syncWechatContent,
zhihu: syncZhihuContent,
toutiao: syncToutiaoContent,
wangyihao: syncWangyihaoContent,
}
const INSPECT_HANDLERS = {
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
jianshu: inspectJianshuTask,
zhihu: inspectZhihuTask,
toutiao: inspectToutiaoTask,
}
// 导出
export { INSPECT_HANDLERS, PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
+58
View File
@@ -0,0 +1,58 @@
// InfoQ 平台配置
const InfoQPlatform = {
id: 'infoq',
name: 'InfoQ',
icon: 'https://static001.infoq.cn/static/write/img/write-favicon.jpg',
url: 'https://xie.infoq.cn',
// InfoQ 需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write
publishUrl: 'https://xie.infoq.cn/draft/write', // 这个 URL 仅作为占位,实际会被动态替换
createDraftApi: 'https://xie.infoq.cn/api/v1/draft/create',
title: 'InfoQ',
type: 'infoq',
}
// InfoQ 内容填充函数
async function fillInfoQContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
// 填充标题
const titleInput = await waitFor(
'input[placeholder*="标题"], .title-input input, input.article-title'
)
if (titleInput) {
setInputValue(titleInput, title)
console.log('[COSE] InfoQ 标题填充成功')
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// InfoQ 使用自定义 Vue 编辑器,通过 readMarkdown 方法填充内容
const gkEditor = document.querySelector('.gk-editor')
if (gkEditor && gkEditor.__vue__) {
const vm = gkEditor.__vue__
if (typeof vm.readMarkdown === 'function') {
try {
vm.readMarkdown(contentToFill)
console.log('[COSE] InfoQ readMarkdown 填充成功')
return
} catch (e) {
console.log('[COSE] InfoQ readMarkdown 失败:', e.message)
}
}
}
// 备用方案:尝试 CodeMirror
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] InfoQ CodeMirror 填充成功')
return
}
console.log('[COSE] InfoQ 未找到编辑器')
}
// 导出
export { InfoQPlatform, fillInfoQContent }
+217
View File
@@ -0,0 +1,217 @@
import { injectUtils } from './common.js'
// 简书平台配置
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 || ''
async function fill() {
const titleInput = await window.waitFor('input._24i7u, input[class*="title"]')
if (titleInput && title) {
titleInput.focus()
const inputSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value',
)?.set
inputSetter?.call(titleInput, title)
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' }),
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
}
await new Promise(resolve => setTimeout(resolve, 500))
const editor = document.querySelector('#arthur-editor')
|| document.querySelector('textarea._3swFR')
if (!editor) return { success: false, error: 'Editor not found' }
editor.focus()
const textareaSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value',
)?.set
textareaSetter?.call(editor, contentToFill)
editor.dispatchEvent(
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' }),
)
editor.dispatchEvent(new Event('change', { bubbles: true }))
return { success: true, method: 'textarea' }
}
return fill()
}
async function syncJianshuContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
const notebooksResponse = await fetch('https://www.jianshu.com/author/notebooks', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!notebooksResponse.ok) {
return { success: false, message: '简书文集读取失败,请确认当前 Chrome 已登录', tabId: tab.id }
}
const notebooks = await notebooksResponse.json()
const notebookId = Array.isArray(notebooks) ? notebooks[0]?.id : undefined
if (!notebookId) {
return { success: false, message: '简书未找到文集,请先创建一个文集', tabId: tab.id }
}
const createResponse = await fetch('https://www.jianshu.com/author/notes', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
notebook_id: String(notebookId),
title: content.title || '无标题',
at_bottom: false,
}),
})
if (!createResponse.ok) {
return { success: false, message: '简书草稿创建失败,请确认登录状态和文集权限', tabId: tab.id }
}
const note = await createResponse.json()
if (!note?.id) {
return { success: false, message: '简书草稿创建结果缺少文章 ID', tabId: tab.id }
}
const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${note.id}`
const updatedTab = await chrome.tabs.update(tab.id, { url: targetUrl })
await waitForTab(updatedTab.id)
await injectUtils(chrome, updatedTab.id)
const result = await chrome.scripting.executeScript({
target: { tabId: updatedTab.id },
func: fillJianshuContent,
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '简书内容填充失败',
tabId: updatedTab.id,
}
}
await new Promise(resolve => setTimeout(resolve, 1000))
return {
success: true,
message: '已同步到简书草稿',
tabId: updatedTab.id,
platformWorkId: String(note.id),
url: targetUrl,
}
}
export {
JianshuPlatform,
fillJianshuContent,
inspectJianshuTask,
inspectJianshuTaskState,
syncJianshuContent,
}
+138
View File
@@ -0,0 +1,138 @@
// 掘金平台配置
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',
}
import { injectUtils } from './common.js'
function inspectJuejinTaskState(rawUrl) {
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取掘金标签页地址' }
}
if (url.protocol !== 'https:' || !['juejin.cn', 'www.juejin.cn'].includes(url.hostname)) {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开掘金,无法确认发布结果',
}
}
const published = url.pathname.match(/^\/post\/([^/]+)\/?$/)
if (published?.[1]) {
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认掘金文章公开地址',
}
}
const draft = url.pathname.match(/^\/editor\/drafts\/([^/]+)\/?$/)
if (draft?.[1] && draft[1] !== 'new') {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draft[1],
message: '已确认掘金草稿地址',
}
}
if (draft?.[1] === 'new') {
return {
status: 'filled',
url: url.href,
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前掘金页面无法确认是草稿还是已发布文章',
}
}
// 掘金内容填充函数(在页面主世界中执行)
// 注意:需要先调用 injectUtils 注入 window.waitFor
function fillJuejinContent(title, markdown, body) {
const contentToFill = markdown || body || ''
async function fill() {
// 填充标题(使用注入的 window.waitFor
const titleInput = await window.waitFor('input[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// 掘金使用 ByteMD 编辑器(基于 CodeMirror
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] 掘金 CodeMirror 填充成功')
return { success: true, method: 'CodeMirror' }
} else {
// 降级到 textarea
const textarea = document.querySelector('.bytemd-body textarea')
if (textarea) {
textarea.focus()
textarea.value = contentToFill
textarea.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 掘金 textarea 填充成功')
return { success: true, method: 'textarea' }
} else {
console.log('[COSE] 掘金 未找到编辑器')
return { success: false, error: 'Editor not found' }
}
}
}
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 new Promise(resolve => setTimeout(resolve, 2000))
// 先注入公共工具函数(waitFor, setInputValue
await injectUtils(chrome, tab.id)
// 在页面中执行填充脚本
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillJuejinContent,
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已同步到掘金', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
}
}
// 导出
export { JuejinPlatform, fillJuejinContent, inspectJuejinTaskState, syncJuejinContent }
@@ -0,0 +1,61 @@
// Medium 平台配置
const MediumPlatform = {
id: 'medium',
name: 'Medium',
icon: 'https://cdn.simpleicons.org/medium',
url: 'https://medium.com',
publishUrl: 'https://medium.com/new-story',
title: 'Medium',
type: 'medium',
}
// Medium 登录检测配置
// Medium 使用 sid 和 uid HttpOnly cookies 进行身份验证
/**
* Medium 内容填充函数
* 流程:
* 1. 等待编辑器加载
* 2. 填充标题到 h3.graf--title
* 3. 通过 paste 事件填充 HTML 内容到编辑器
*/
async function fillMediumContent(content, waitFor, setInputValue) {
const { title, body, html } = content
const htmlContent = html || body || ''
console.log('[COSE] Medium 开始同步...')
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 2000))
// 第一步:填充标题
const titleEl = document.querySelector('h3.graf--title')
if (titleEl && title) {
titleEl.focus()
titleEl.textContent = title
titleEl.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] Medium 标题填充成功')
}
// 第二步:填充内容 - 使用 paste 事件
const contentEl = document.querySelector('p.graf--p')
if (contentEl && htmlContent) {
contentEl.focus()
// 创建 DataTransfer 并设置 HTML 内容
const dt = new DataTransfer()
dt.setData('text/html', htmlContent)
dt.setData('text/plain', htmlContent.replace(/<[^>]*>/g, ''))
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt,
})
contentEl.dispatchEvent(pasteEvent)
console.log('[COSE] Medium 内容填充成功')
}
}
// 导出
export { MediumPlatform, fillMediumContent }
@@ -0,0 +1,15 @@
// ModelScope 魔搭社区平台配置
// 编辑器支持 Markdown,注入后需要点击"转为富文本"按钮
const ModelScopePlatform = {
id: 'modelscope',
name: 'ModelScope',
icon: 'https://img.alicdn.com/imgextra/i4/O1CN01fvt4it25rEZU4Gjso_!!6000000007579-2-tps-128-128.png',
url: 'https://modelscope.cn',
publishUrl: 'https://modelscope.cn/learn/create',
title: 'ModelScope 魔搭社区',
type: 'modelscope',
}
// 导出
export { ModelScopePlatform }
@@ -0,0 +1,85 @@
// OSChina 平台配置
const OSChinaPlatform = {
id: 'oschina',
name: 'OSChina',
icon: 'https://wsrv.nl/?url=static.oschina.net/new-osc/img/favicon.ico',
url: 'https://www.oschina.net',
publishUrl: 'https://my.oschina.net/blog/ai-write',
title: '开源中国',
type: 'oschina',
}
// OSChina 内容填充函数 (AI 写作平台 - 切换到 Markdown 编辑器)
async function fillOSChinaContent(content, waitFor, setInputValue) {
const { title, markdown, body } = content
const mdContent = markdown || body || ''
// 1. 切换到 MD 编辑器(如果当前不是)
const switchText = document.querySelector('.editor-switch-text')
if (switchText && switchText.textContent.includes('切换到MD编辑器')) {
const switchBtn = document.querySelector('.editor-switch-btn') || switchText.parentElement
if (switchBtn) {
switchBtn.click()
let confirmBtn = null
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setTimeout(resolve, 200))
confirmBtn = Array.from(document.querySelectorAll('button')).find(
btn => btn.textContent.trim() === '确定切换'
)
if (confirmBtn) break
}
if (confirmBtn) {
confirmBtn.click()
console.log('[COSE] OSChina 已确认切换到MD编辑器')
}
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
// 2. 填充标题
const titleInput = await waitFor('input[placeholder*="标题"]')
if (titleInput) {
titleInput.focus()
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
if (nativeSetter) {
nativeSetter.call(titleInput, title)
} else {
titleInput.value = title
}
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] OSChina 标题填充成功')
}
// 3. 填充 Markdown 内容到 textarea
await new Promise(resolve => setTimeout(resolve, 500))
let textarea = null
for (let i = 0; i < 10; i++) {
textarea = document.querySelector('textarea')
if (textarea) break
await new Promise(resolve => setTimeout(resolve, 300))
}
if (textarea) {
textarea.focus()
const textareaSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)?.set
if (textareaSetter) {
textareaSetter.call(textarea, mdContent)
} else {
textarea.value = mdContent
}
textarea.dispatchEvent(new Event('input', { bubbles: true }))
textarea.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] OSChina Markdown 内容填充成功')
} else {
console.log('[COSE] OSChina 未找到 Markdown textarea')
}
}
// 导出
export { OSChinaPlatform, fillOSChinaContent }
+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 }
@@ -0,0 +1,50 @@
// 思否平台配置
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',
}
// 思否内容填充函数
async function fillSegmentFaultContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
// 填充标题
const titleInput = await waitFor('input#title, input[placeholder*="标题"]')
if (titleInput) {
titleInput.focus()
titleInput.value = title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 思否标题填充成功')
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 1000))
// 思否使用 CodeMirror 编辑器
const cmElement = document.querySelector('.CodeMirror')
if (cmElement && cmElement.CodeMirror) {
cmElement.CodeMirror.setValue(contentToFill)
console.log('[COSE] 思否 CodeMirror 填充成功')
} else {
// 降级到 textarea
const textarea = document.querySelector('textarea')
if (textarea) {
textarea.focus()
textarea.value = contentToFill
textarea.dispatchEvent(new Event('input', { bubbles: true }))
console.log('[COSE] 思否 textarea 填充成功')
} else {
console.log('[COSE] 思否 未找到编辑器')
}
}
}
// 导出
export { SegmentFaultPlatform, fillSegmentFaultContent }
+19
View File
@@ -0,0 +1,19 @@
// 搜狐号平台配置
const SohuPlatform = {
id: 'sohu',
name: 'Sohu',
icon: 'https://statics.itc.cn/mp-new/icon/1.1/favicon.ico',
url: 'https://mp.sohu.com',
publishUrl: 'https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?contentStatus=1',
title: '搜狐号',
type: 'sohu',
}
// 搜狐号内容填充函数
// 注意:搜狐号由 syncToPlatform 单独处理,此函数作为备用
async function fillSohuContent(content, waitFor) {
console.log('[COSE] 搜狐号由 syncToPlatform 处理')
}
// 导出
export { SohuPlatform, fillSohuContent }
+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,150 @@
// 腾讯云开发者平台配置
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',
}
/**
* 检查当前是否需要切换到 MD 编辑器
* 判断依据:页面中是否存在"切换 MD 编辑器"的按钮
* - 如果存在,说明当前是富文本编辑器,需要切换
* - 如果不存在(显示"切换 富文本 编辑器"),说明已经是 MD 编辑器
* @returns {HTMLElement|null} 返回切换按钮元素,如果已经是 MD 编辑器则返回 null
*/
function findSwitchToMDButton() {
const headerBtns = document.querySelectorAll('.header-btn')
for (const btn of headerBtns) {
// 只有当按钮文本包含"MD"时才需要切换(说明当前是富文本编辑器)
// 如果按钮文本包含"富文本",说明已经是 MD 编辑器,不需要切换
if (btn.textContent.includes('切换') && btn.textContent.includes('MD')) {
return btn
}
}
return null
}
/**
* 确保编辑器处于 Markdown 模式
* 1. 检查是否有"切换 MD 编辑器"按钮
* 2. 如果有,点击切换到 MD 编辑器
* 3. 如果没有,说明已经是 MD 编辑器
* @returns {Promise<boolean>} 是否成功进入 MD 编辑器模式
*/
async function ensureMarkdownEditor() {
// 等待页面加载完成
await new Promise(resolve => setTimeout(resolve, 1000))
const switchBtn = findSwitchToMDButton()
if (switchBtn) {
// 找到了"切换 MD 编辑器"按钮,说明当前是富文本编辑器,需要切换
console.log('[COSE] TencentCloud 检测到富文本编辑器,正在切换到 MD 编辑器...')
switchBtn.click()
// 等待切换完成
await new Promise(resolve => setTimeout(resolve, 1500))
// 验证切换是否成功:检查 CodeMirror 是否存在
const cm = document.querySelector('.CodeMirror')
if (cm && cm.CodeMirror) {
console.log('[COSE] TencentCloud 成功切换到 MD 编辑器')
return true
}
// 如果 CodeMirror 还没加载,再等待一下
await new Promise(resolve => setTimeout(resolve, 1000))
const cmRetry = document.querySelector('.CodeMirror')
if (cmRetry && cmRetry.CodeMirror) {
console.log('[COSE] TencentCloud 成功切换到 MD 编辑器(延迟加载)')
return true
}
console.error('[COSE] TencentCloud 切换失败:CodeMirror 未加载')
return false
} else {
// 没有找到"切换 MD 编辑器"按钮,说明已经是 MD 编辑器
console.log('[COSE] TencentCloud 当前已是 MD 编辑器')
// 验证 CodeMirror 是否存在
const cm = document.querySelector('.CodeMirror')
if (cm && cm.CodeMirror) {
return true
}
// 等待 CodeMirror 加载
await new Promise(resolve => setTimeout(resolve, 1000))
const cmRetry = document.querySelector('.CodeMirror')
return !!(cmRetry && cmRetry.CodeMirror)
}
}
/**
* 获取 CodeMirror 实例
* @param {number} maxWait 最大等待时间(毫秒)
* @returns {Promise<CodeMirror|null>}
*/
async function getCodeMirror(maxWait = 3000) {
const startTime = Date.now()
while (Date.now() - startTime < maxWait) {
const cm = document.querySelector('.CodeMirror')
if (cm && cm.CodeMirror) {
return cm.CodeMirror
}
await new Promise(resolve => setTimeout(resolve, 200))
}
return null
}
/**
* 腾讯云内容填充函数
* 流程:
* 1. 确保进入 MD 编辑器模式
* 2. 填充标题
* 3. 填充内容到 CodeMirror
*/
async function fillTencentCloudContent(content, waitFor, setInputValue) {
const { title, body, markdown } = content
const contentToFill = markdown || body || ''
console.log('[COSE] TencentCloud 开始同步...')
// 第一步:确保进入 MD 编辑器模式
const isMarkdownMode = await ensureMarkdownEditor()
if (!isMarkdownMode) {
console.error('[COSE] TencentCloud 错误:无法进入 MD 编辑器模式,请手动切换后重试')
return
}
// 第二步:获取 CodeMirror 实例
const codeMirror = await getCodeMirror(3000)
if (!codeMirror) {
console.error('[COSE] TencentCloud 错误:CodeMirror 未加载,请刷新页面后重试')
return
}
// 第三步:填充标题
const titleInput = document.querySelector('textarea[placeholder*="标题"]')
if (titleInput && title) {
titleInput.focus()
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] TencentCloud 标题填充成功')
}
// 第四步:填充内容到 CodeMirror
codeMirror.setValue(contentToFill)
console.log('[COSE] TencentCloud 内容填充成功')
}
// 导出
export { TencentCloudPlatform, fillTencentCloudContent, ensureMarkdownEditor, getCodeMirror }
+441
View File
@@ -0,0 +1,441 @@
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, markdown, body) {
const contentToFill = markdown || body || ''
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
function findFirst(selectors) {
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const 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) {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
input.focus()
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function dispatchHtmlPaste(editor, html, plainText) {
editor.focus()
selectEditorContents(editor)
if (typeof DataTransfer !== 'undefined' && typeof ClipboardEvent !== 'undefined') {
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', plainText)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
return false
}
async function fill() {
const titleInput = await waitForElement([
'.editor-title textarea',
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到今日头条标题输入框' }
if (title) setInputValue(titleInput, title)
const editor = await waitForElement([
'.ProseMirror[contenteditable="true"]',
'.ProseMirror',
'[contenteditable="true"][role="textbox"]',
])
if (!editor) return { success: false, error: '未找到今日头条正文编辑器' }
if (!contentToFill) return { success: true, method: 'title-only' }
const html = renderToutiaoMarkdown(contentToFill)
const plainText = renderToutiaoPlainText(contentToFill)
dispatchHtmlPaste(editor, html, plainText)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
const expectedTextLength = plainText.length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
let editorTextLength = normalizeText(editor.innerText || editor.textContent).length
let imageCount = editor.querySelectorAll('img').length
if (editorTextLength + imageCount * 12 < minimumLength && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
await sleep(500)
editorTextLength = normalizeText(editor.innerText || editor.textContent).length
imageCount = editor.querySelectorAll('img').length
}
if (editorTextLength + imageCount * 12 < minimumLength) {
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 result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillToutiaoContent,
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 {
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,117 @@
// 网易号平台配置
const WangyihaoPlatform = {
id: 'wangyihao',
name: 'Wangyihao',
icon: 'https://static.ws.126.net/163/f2e/news/yxybd_pc/resource/static/share-icon.png',
url: 'https://mp.163.com',
publishUrl: 'https://mp.163.com/#/article-publish',
title: '网易号',
type: 'wangyihao',
}
import { injectUtils } from './common.js'
// 网易号内容填充函数(在页面主世界中执行)
// 网易号使用剪贴板 HTML 粘贴到 Draft.js 编辑器
function fillWangyihaoContent(title, htmlBody) {
async function fill() {
// 1. 等待并填充标题 - 网易号使用 textarea.netease-textarea
const titleInput =
(await window.waitFor('textarea.netease-textarea', 10000)) ||
(await window.waitFor('textarea[placeholder*="标题"]', 3000))
if (titleInput && title) {
titleInput.focus()
// 使用 native setter 来绕过 React 的受控组件
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
// 触发 React 能识别的事件
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 网易号标题已填充')
} else {
console.log('[COSE] 网易号未找到标题输入框')
}
// 2. 等待 Draft.js 编辑器出现
const editor =
(await window.waitFor('.public-DraftEditor-content', 10000)) ||
(await window.waitFor('[contenteditable="true"]', 3000))
if (editor && htmlBody) {
editor.focus()
// 清空 Draft.js 占位符
const placeholder = editor.querySelector('[data-text="true"]')
if (placeholder && placeholder.textContent.includes('请输入正文')) {
editor.innerHTML = ''
}
// 通过 paste 事件注入 HTML 内容
const dt = new DataTransfer()
dt.setData('text/html', htmlBody)
dt.setData('text/plain', htmlBody.replace(/<[^>]*>/g, ''))
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dt,
})
editor.dispatchEvent(pasteEvent)
console.log('[COSE] 网易号内容已通过 paste 事件注入')
return { success: true }
} else {
console.log('[COSE] 网易号未找到编辑器元素')
return { success: false, error: 'Editor not found' }
}
}
return fill()
}
/**
* 网易号同步处理器
* 网易号使用剪贴板 HTML 粘贴到 Draft.js 编辑器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown, wechatHtml }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncWangyihaoContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
// 等待页面加载完成(waitForTab 使用 chrome.tabs.onUpdated 监听)
await waitForTab(tab.id)
// 先注入公共工具函数(waitFor 使用 MutationObserver
await injectUtils(chrome, tab.id)
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 网易号 HTML 内容长度:', htmlContent?.length || 0)
// 在页面中执行:填充标题和粘贴 HTML 内容
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillWangyihaoContent,
args: [content.title, htmlContent],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已同步到网易号', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '网易号内容填充失败', tabId: tab.id }
}
}
// 导出
export { WangyihaoPlatform, fillWangyihaoContent, 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 }
+291
View File
@@ -0,0 +1,291 @@
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 => (value || '').replace(/\s+/g, '')
function findFirst(selectors) {
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
function 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)
const editor = await waitForElement([
'.public-DraftEditor-content[contenteditable="true"]',
'.public-DraftEditor-content',
'.DraftEditor-root [contenteditable="true"]',
'.WriteIndex-content [contenteditable="true"]',
'[contenteditable="true"][role="textbox"]',
])
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 expectedTextLength = normalizeText(contentToFill
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
.replace(/[\[\]#*_>`~()]/g, ''))
.length
const editorTextLength = normalizeText(editor.innerText || editor.textContent).length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
const imageEquivalentLength = editor.querySelectorAll('img').length * 12
if (editorTextLength + imageEquivalentLength < minimumLength) {
return {
success: false,
error: '知乎未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
return {
success: true,
method: parsed ? 'markdown-parse' : 'paste',
}
}
return 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'
@@ -0,0 +1,98 @@
/**
* @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 const BaijiahaoLoginConfig = {
api: 'https://baijiahao.baidu.com/builder/app/appinfo',
method: 'GET',
checkLogin: response => response?.errno === 0 && response?.data?.user?.name,
getUserInfo: response => ({
username: response?.data?.user?.name,
avatar: response?.data?.user?.avatar,
}),
}
// 抖音
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,
cto51: detectCTO51User,
jianshu: detectJianshuUser,
segmentfault: detectSegmentFaultUser,
infoq: detectInfoQUser,
modelscope: detectModelScopeUser,
volcengine: detectVolcengineUser,
cnblogs: detectCnblogsUser,
wangyihao: 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,36 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* Aliyun Developer platform detection logic
* Strategy:
* 1. Check login_aliyunid_ticket cookie
* 2. Call getUser API for username/avatar
* 3. Convert avatar to base64 to bypass CORS/ORB
*/
export async function detectAliyunUser() {
try {
const ticketCookie = await chrome.cookies.get({
url: 'https://developer.aliyun.com',
name: 'login_aliyunid_ticket',
})
if (!ticketCookie || !ticketCookie.value) return { loggedIn: false }
const response = await fetch('https://developer.aliyun.com/developer/api/my/user/getUser', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
const data = await response.json()
if (data.success && data.data?.nickname) {
let avatar = data.data.avatar || ''
if (avatar) {
avatar = await convertAvatarToBase64(avatar, 'https://developer.aliyun.com/')
}
return { loggedIn: true, username: data.data.nickname, avatar }
}
return { loggedIn: false }
} catch (e) {
return { loggedIn: false }
}
}
@@ -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,69 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* CSDN platform detection logic
* Strategy:
* 1. Check 'UserName' cookie (reliable indicator of login)
* 2. Use 'UserNick' cookie for display name (UserName is the user ID, UserNick is the display name)
* 3. If logged in, fetch public blog page to get avatar
*/
export async function detectCSDNUser() {
try {
console.log('[COSE] CSDN Detection: Starting cookie check')
const userNameCookie = await chrome.cookies.get({
url: 'https://www.csdn.net',
name: 'UserName',
})
if (userNameCookie && userNameCookie.value) {
const userId = userNameCookie.value
console.log(`[COSE] CSDN UserName cookie found: ${userId}`)
// UserNick cookie contains the display name (e.g. 'xxxxxx')
const userNickCookie = await chrome.cookies.get({
url: 'https://www.csdn.net',
name: 'UserNick',
})
const username =
userNickCookie && userNickCookie.value ? decodeURIComponent(userNickCookie.value) : userId
console.log(`[COSE] CSDN display name: ${username}`)
let avatar = ''
try {
// Fetch public blog page for avatar
const blogUrl = `https://blog.csdn.net/${userId}`
const blogResp = await fetch(blogUrl, { method: 'GET' })
const blogHtml = await blogResp.text()
// Extract avatar
const avatarMatch =
blogHtml.match(
/<img[^>]*src=["'](https:\/\/(?:profile|i-avatar)\.csdnimg\.cn\/[^"']+)["']/i
) || blogHtml.match(/<img[^>]*class=["']avatar[^"']*["'][^>]*src=["']([^"']+)["']/i)
if (avatarMatch) {
avatar = avatarMatch[1]
}
} catch (e) {
console.warn('[COSE] CSDN Avatar fetch failed:', e)
}
// Convert csdnimg.cn avatar to base64 data URL to bypass CORS/ORB
if (avatar && avatar.includes('csdnimg.cn')) {
avatar = await convertAvatarToBase64(avatar, 'https://blog.csdn.net/')
}
return {
loggedIn: true,
username: username,
avatar: avatar,
}
}
console.log('[COSE] CSDN: No login detected')
return { loggedIn: false }
} catch (e) {
console.error('[COSE] CSDN Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
@@ -0,0 +1,35 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* 51CTO platform detection logic (same approach as 爱贝壳)
* Fetch https://home.51cto.com/space via offscreen document,
* parse HTML with DOMParser to extract avatar, uid, nickname.
*/
export async function detectCTO51User() {
try {
console.log('[COSE] 51CTO Detection: Starting (offscreen)')
if (typeof globalThis.__coseDetectCto51 === 'function') {
const result = await globalThis.__coseDetectCto51()
if (result && result.loggedIn) {
let avatar = result.avatar || ''
if (avatar && avatar.startsWith('http') && avatar.includes('51cto.com')) {
avatar = await convertAvatarToBase64(avatar, 'https://home.51cto.com/')
}
console.log('[COSE] 51CTO: Logged in:', result.username)
return { loggedIn: true, username: result.username || '', avatar }
}
// Pass through debug info if present
if (result && result._debug) {
console.log('[COSE] 51CTO: Not logged in, debug:', JSON.stringify(result._debug))
return { loggedIn: false, _debug: result._debug }
}
}
console.log('[COSE] 51CTO: Not logged in')
return { loggedIn: false }
} catch (e) {
console.error('[COSE] 51CTO Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
@@ -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,41 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* InfoQ platform detection logic
* Strategy: POST to /public/v1/user/get_user API to get user info
* The old /public/v1/my/menu endpoint returns 404.
*/
export async function detectInfoQUser() {
try {
console.log('[COSE] InfoQ Detection: Starting')
const response = await fetch('https://www.infoq.cn/public/v1/user/get_user', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({}),
})
if (!response.ok) return { loggedIn: false }
const json = await response.json()
if (json?.code !== 0 || !json?.data?.uid) {
console.log('[COSE] InfoQ: Not logged in', json?.code)
return { loggedIn: false }
}
const username = json.data.nickname || ''
let avatar = json.data.avatar || ''
if (avatar && avatar.includes('geekbang.org')) {
avatar = await convertAvatarToBase64(avatar, 'https://www.infoq.cn/')
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] InfoQ Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
@@ -0,0 +1,49 @@
import { convertAvatarToBase64 } from '../utils.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 response = await fetch('https://www.jianshu.com/settings/basic.json', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!response.ok) return { loggedIn: false }
const json = await response.json()
const account = parseJianshuAccount(json)
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 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* OSChina platform detection logic
* Strategy:
* 1. Check oscid cookie existence as login indicator (MV3 service worker compatible)
* 2. Best-effort: fetch user info via API for username and avatar
*/
export async function detectOSChinaUser() {
try {
// Check oscid cookie as login indicator
const oscidCookie = await chrome.cookies.get({ url: 'https://www.oschina.net', name: 'oscid' })
if (!oscidCookie || !oscidCookie.value) {
return { loggedIn: false }
}
// Collect all cookies for API request
const cookies = await chrome.cookies.getAll({ domain: '.oschina.net' })
const wwwCookies = await chrome.cookies.getAll({ url: 'https://www.oschina.net' })
const apiCookies = await chrome.cookies.getAll({ url: 'https://apiv1.oschina.net' })
const allCookies = [...cookies, ...wwwCookies, ...apiCookies]
const seen = new Set()
const uniqueCookies = allCookies.filter(c => {
const key = `${c.name}=${c.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
// Best-effort: try to get username and avatar via API
let username = ''
let avatar = ''
let userId = ''
try {
const response = await fetch('https://apiv1.oschina.net/oschinapi/user/myDetails', {
method: 'GET',
headers: {
Accept: 'application/json',
Cookie: cookieStr,
},
})
if (response.ok) {
const data = await response.json()
if (data?.success && data?.result?.userVo) {
username = data.result.userVo.name || ''
avatar = data.result.userVo.portraitUrl || ''
userId = String(data.result.userVo.id || '')
}
}
} catch (e) {
console.log('[COSE] OSChina: API fetch failed, using cookie-only detection')
}
if (avatar && (avatar.includes('oschina.net') || avatar.includes('oscimg'))) {
avatar = await convertAvatarToBase64(avatar, 'https://www.oschina.net/')
}
// Store userId for sync URL construction
if (userId) {
try {
await chrome.storage.local.set({ oschina_userId: userId })
} catch (e) {
/* ignore */
}
}
return { loggedIn: true, username, avatar, userId }
} catch (e) {
console.error('[COSE] OSChina Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
@@ -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,47 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* SegmentFault platform detection logic
* Strategy: Fetch homepage HTML and extract user info from __NEXT_DATA__ JSON
* The /gateway/user/me API requires CSRF tokens (ivd param), so we use HTML scraping instead.
*/
export async function detectSegmentFaultUser() {
try {
console.log('[COSE] SegmentFault Detection: Starting')
const response = await fetch('https://segmentfault.com/', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'text/html' },
})
const html = await response.text()
// Extract __NEXT_DATA__ JSON
const nextDataMatch = html.match(/<script\s+id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)
if (!nextDataMatch) {
console.log('[COSE] SegmentFault: No __NEXT_DATA__ found')
return { loggedIn: false }
}
const nextData = JSON.parse(nextDataMatch[1])
const sessionUser = nextData?.props?.pageProps?.initialState?.global?.sessionUser
const sessionInfo = nextData?.props?.pageProps?.initialState?.global?.sessionInfo
if (!sessionUser?.user?.id && !sessionInfo?.login) {
console.log('[COSE] SegmentFault: Not logged in')
return { loggedIn: false }
}
const user = sessionUser?.user || {}
const username = user.name || user.slug || ''
let avatar = user.avatar_url || ''
if (avatar && avatar.includes('segmentfault.com')) {
avatar = await convertAvatarToBase64(avatar, 'https://segmentfault.com/')
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] SegmentFault Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
@@ -0,0 +1,33 @@
/**
* Sohu (搜狐号) platform detection logic
* Strategy:
* 1. Check ppinf cookie on mp.sohu.com
* 2. Call account list API for nickname/avatar
*/
export async function detectSohuUser() {
try {
const ppinfCookie = await chrome.cookies.get({ url: 'https://mp.sohu.com', name: 'ppinf' })
if (!ppinfCookie || !ppinfCookie.value) return { loggedIn: false }
try {
const response = await fetch('https://mp.sohu.com/mpbp/bp/account/list', {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
})
const data = await response.json()
if (data.success && data.data?.data?.[0]?.accounts?.[0]) {
const account = data.data.data[0].accounts[0]
let avatar = account.avatar || ''
if (avatar.startsWith('//')) avatar = 'https:' + avatar
return { loggedIn: true, username: account.nickName, avatar }
} else {
return { loggedIn: true, username: '', avatar: '' }
}
} catch (e) {
return { loggedIn: true, username: '', avatar: '' }
}
} catch (e) {
return { loggedIn: false }
}
}
@@ -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,55 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* Tencent Cloud platform detection logic
* Strategy:
* 1. Fetch creator page
* 2. Check redirect and parse HTML for nickname/avatar
*/
export async function detectTencentCloudUser() {
try {
const response = await fetch('https://cloud.tencent.com/developer/creator', {
method: 'GET',
credentials: 'include',
})
const html = await response.text()
const finalUrl = response.url
if (!finalUrl.includes('/creator')) return { loggedIn: false }
if (
html.includes('登录/注册') ||
html.includes('"isLogin":false') ||
html.includes('"login":false')
)
return { loggedIn: false }
const userInfoMatch =
html.match(/"userInfo"\s*:\s*\{[^}]*"nickname"\s*:\s*"([^"]+)"[^}]*\}/) ||
html.match(/"creatorInfo"\s*:\s*\{[^}]*"nickname"\s*:\s*"([^"]+)"[^}]*\}/) ||
html.match(/"currentUser"\s*:\s*\{[^}]*"nickname"\s*:\s*"([^"]+)"[^}]*\}/)
const creatorNicknameMatch =
html.match(
/class="creator-info[^"]*"[^>]*>[\s\S]*?<[^>]*class="[^"]*name[^"]*"[^>]*>([^<]+)</
) || html.match(/"isCreator"\s*:\s*true[\s\S]*?"nickname"\s*:\s*"([^"]+)"/)
const nicknameMatch = userInfoMatch || creatorNicknameMatch
const avatarMatch =
html.match(/"userInfo"[\s\S]*?"avatarUrl"\s*:\s*"([^"]+)"/) ||
html.match(/"avatar"\s*:\s*"(https?:\/\/[^"]+)"/)
if (nicknameMatch && nicknameMatch[1]) {
let avatar = avatarMatch ? avatarMatch[1] : ''
if (avatar && avatar.includes('qcloudimg.com')) {
avatar = await convertAvatarToBase64(avatar, 'https://cloud.tencent.com/')
}
return { loggedIn: true, username: nicknameMatch[1], avatar }
} else {
if (html.includes('创作中心') || html.includes('我的文章'))
return { loggedIn: true, username: '', avatar: '' }
return { loggedIn: false }
}
} catch (e) {
return { loggedIn: false }
}
}
@@ -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,51 @@
import { convertAvatarToBase64 } from '../utils.js'
/**
* 网易号 detection logic
* Strategy:
* 1. Collect cookies via chrome.cookies.getAll (MV3 service worker compatible)
* 2. Fetch user info via mp.163.com/wemedia/navinfo.do with cookies attached manually
* 3. Extract username and avatar from API response
*/
export async function detectWangyihaoUser() {
try {
const cookies = await chrome.cookies.getAll({ domain: '.163.com' })
const mpCookies = await chrome.cookies.getAll({ url: 'https://mp.163.com' })
const allCookies = [...cookies, ...mpCookies]
const seen = new Set()
const uniqueCookies = allCookies.filter(c => {
const key = `${c.name}=${c.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
if (!cookieStr) return { loggedIn: false }
const response = await fetch(`https://mp.163.com/wemedia/navinfo.do?_=${Date.now()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
Cookie: cookieStr,
},
})
if (!response.ok) return { loggedIn: false }
const data = await response.json()
if (data?.code !== 1 || !data?.data?.wemediaId) return { loggedIn: false }
const username = data.data.tname || ''
let avatar = data.data.icon || ''
if (avatar && (avatar.includes('126.net') || avatar.includes('163.com'))) {
avatar = await convertAvatarToBase64(avatar, 'https://mp.163.com/')
}
return { loggedIn: true, username, avatar }
} catch (e) {
console.error('[COSE] Wangyihao Detection Error:', e)
return { loggedIn: false, error: e.message }
}
}
@@ -0,0 +1,104 @@
/**
* 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) {
console.log(`[COSE] wechat 从缓存读取:`, cachedUser.username)
return {
loggedIn: true,
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,
username: wxData.nick_name || wxData.user_name || '',
avatar: wxData.head_img || '',
token: wxData.t || '',
}
}
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,
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*["']([^"']+)["']/)
if (nickMatch) {
const username = nickMatch[1]
const avatar = avatarMatch ? avatarMatch[1] : ''
await chrome.storage.local.set({
wechat_user: {
loggedIn: true,
username,
avatar,
cachedAt: Date.now(),
},
})
return { loggedIn: true, 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 }
}
}
+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>
+220
View File
@@ -0,0 +1,220 @@
// 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_CTO51') {
handleDetectCto51()
.then(result => sendResponse({ success: true, data: result }))
.catch(err => sendResponse({ success: false, error: err.message }))
return true
}
if (message.type === 'OFFSCREEN_DETECT_CNBLOGS') {
handleDetectCnblogs()
.then(result => sendResponse({ success: true, data: result }))
.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 }
}
}
/**
* 51CTO detection: fetch home.51cto.com/space and parse with DOMParser.
* Same approach as 爱贝壳 extension - runs in document context (offscreen).
*/
async function handleDetectCto51() {
try {
const resp = await fetch('https://home.51cto.com/space', {
credentials: 'include',
})
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
// Avatar: <img alt="头像">
const avatarEl = doc.querySelector("img[alt='头像']")
const avatar = avatarEl ? avatarEl.getAttribute('src') : ''
// UID from avatar URL: uid=(\d+)
let uid = ''
if (avatar) {
const m = avatar.match(/uid=(\d+)/)
if (m) uid = m[1]
}
// Nickname: div.name > a
const nameEl = doc.querySelector('div.name > a')
const username = nameEl ? nameEl.textContent.trim() : ''
if (!username && !uid) {
// Return debug info to help diagnose
const title = doc.querySelector('title')?.textContent || ''
return {
loggedIn: false,
_debug: { status: resp.status, url: resp.url, htmlLen: html.length, title },
}
}
return { loggedIn: true, username, avatar, uid }
} catch (e) {
return { loggedIn: false, error: e.message }
}
}
/**
* Cnblogs detection: fetch account.cnblogs.com/user/userinfo in document context.
* Cookies are sent automatically (unlike service worker fetch which strips Cookie headers).
*/
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);
}
}
+81 -12
View File
@@ -1,32 +1,101 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.0.0",
"description": "智能内容提取与多平台一键发布助手,助力内容营销效率提升",
"default_locale": "zh_CN",
"version": "1.7.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
"permissions": [
"activeTab",
"clipboardRead",
"cookies",
"storage",
"contextMenus",
"debugger",
"declarativeNetRequest",
"notifications",
"offscreen",
"scripting",
"tabGroups",
"tabs"
],
"host_permissions": [
"http://localhost:3002/*",
"http://127.0.0.1:3002/*"
"http://localhost:6061/*",
"http://127.0.0.1:6061/*",
"https://wx.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://*.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 +103,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)
: [];
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);
}
});
+9
View File
@@ -0,0 +1,9 @@
{
"name": "aitoearn-chrome-extension",
"private": true,
"type": "module",
"scripts": {
"test": "node scripts/test-interaction.mjs && node scripts/test-distribution.mjs",
"package": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1"
}
}
+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>
+179 -268
View File
@@ -1,307 +1,218 @@
// ===== AiToEarn Popup Script =====
const PREPARE_BATCH_MESSAGE = 'PREPARE_PLATFORM_BATCH';
const DEFAULT_API_URL = 'http://localhost:3002';
// ===== DOM Elements =====
const $ = (sel) => document.querySelector(sel);
const = (sel) => document.querySelectorAll(sel);
const extractBtn = #extractBtn;
const extractStatus = #extractStatus;
const titleInput = #titleInput;
const contentInput = #contentInput;
const charCount = #charCount;
const imagePreview = #imagePreview;
const previewImg = #previewImg;
const removeImage = #removeImage;
const noImage = #noImage;
const aiPolish = #aiPolish;
const publishBtn = #publishBtn;
const statusBar = #statusBar;
const statusIcon = #statusIcon;
const statusText = #statusText;
const loadingOverlay = #loadingOverlay;
const loadingText = #loadingText;
const settingsBtn = #settingsBtn;
const selectAllBtn = #selectAll;
const selectNoneBtn = #selectNone;
const extractBtn = document.querySelector('#extractBtn');
const extractStatus = document.querySelector('#extractStatus');
const titleInput = document.querySelector('#titleInput');
const contentInput = document.querySelector('#contentInput');
const charCount = document.querySelector('#charCount');
const platformGrid = document.querySelector('#platformGrid');
const publishBtn = document.querySelector('#publishBtn');
const statusBar = document.querySelector('#statusBar');
const settingsBtn = document.querySelector('#settingsBtn');
const selectAllBtn = document.querySelector('#selectAll');
const selectNoneBtn = document.querySelector('#selectNone');
const extensionVersion = document.querySelector('#extensionVersion');
let extractedData = null;
let selectedImageUrl = null;
let availablePlatforms = [];
// ===== Initialize =====
async function init() {
await loadSettings();
await loadPlatformPrefs();
setupEventListeners();
updatePublishButton();
}
// ===== Settings =====
async function loadSettings() {
const data = await chrome.storage.sync.get({
apiUrl: DEFAULT_API_URL,
apiKey: '',
defaultPlatforms: ['wechat'],
autoPolish: false
});
aiPolish.checked = data.autoPolish;
}
async function saveSettings(updates) {
await chrome.storage.sync.set(updates);
}
// ===== Platform Preferences =====
async function loadPlatformPrefs() {
const data = await chrome.storage.sync.get({ defaultPlatforms: ['wechat'] });
const saved = data.defaultPlatforms || [];
('input[name="platform"]').forEach(cb => {
cb.checked = saved.includes(cb.value);
});
}
async function savePlatformPrefs() {
const platforms = getSelectedPlatforms();
await chrome.storage.sync.set({ defaultPlatforms: platforms });
async function sendRuntimeMessage(message) {
const response = await chrome.runtime.sendMessage(message);
if (response?.error) throw new Error(response.error);
return response;
}
function getSelectedPlatforms() {
return Array.from(('input[name="platform"]:checked')).map(cb => cb.value);
return Array.from(document.querySelectorAll('input[name="platform"]:checked'))
.map(input => input.value);
}
// ===== Event Listeners =====
function setupEventListeners() {
extractBtn.addEventListener('click', extractCurrentPage);
publishBtn.addEventListener('click', publishToPlatforms);
settingsBtn.addEventListener('click', () => chrome.runtime.openOptionsPage());
removeImage.addEventListener('click', clearImage);
contentInput.addEventListener('input', () => {
charCount.textContent = contentInput.value.length;
updatePublishButton();
});
titleInput.addEventListener('input', updatePublishButton);
selectAllBtn.addEventListener('click', () => {
('input[name="platform"]').forEach(cb => { cb.checked = true; });
savePlatformPrefs();
updatePublishButton();
});
selectNoneBtn.addEventListener('click', () => {
('input[name="platform"]').forEach(cb => { cb.checked = false; });
savePlatformPrefs();
updatePublishButton();
});
('input[name="platform"]').forEach(cb => {
cb.addEventListener('change', () => {
savePlatformPrefs();
updatePublishButton();
});
});
function updatePublishButton() {
const hasContent = Boolean(titleInput.value.trim() || contentInput.value.trim());
publishBtn.disabled = !hasContent || getSelectedPlatforms().length === 0;
}
function applyExtractedData(data) {
extractedData = data;
titleInput.value = data?.title || '';
contentInput.value = data?.body || '';
charCount.textContent = String(contentInput.value.length);
updatePublishButton();
}
function renderPlatforms(selectedIds) {
platformGrid.replaceChildren();
if (availablePlatforms.length === 0) {
const empty = document.createElement('p');
empty.className = 'platform-empty';
empty.textContent = '未读取到可用平台';
platformGrid.append(empty);
return;
}
const selected = new Set(selectedIds);
for (const platform of availablePlatforms) {
const label = document.createElement('label');
label.className = 'checkbox-label';
const input = document.createElement('input');
input.type = 'checkbox';
input.name = 'platform';
input.value = platform.id;
input.checked = selected.has(platform.id);
const text = document.createElement('span');
text.className = 'checkbox-text';
text.textContent = platform.title || platform.name || platform.id;
label.append(input, text);
platformGrid.append(label);
}
}
async function loadPlatforms() {
const [response, preferences] = await Promise.all([
sendRuntimeMessage({ type: 'GET_PLATFORMS' }),
chrome.storage.sync.get({ defaultPlatforms: ['wechat'] }),
]);
availablePlatforms = Array.isArray(response?.platforms)
? response.platforms.filter(platform => platform?.id && platform?.publishUrl)
: [];
const availableIds = new Set(availablePlatforms.map(platform => platform.id));
const selectedIds = preferences.defaultPlatforms.filter(platformId => availableIds.has(platformId));
renderPlatforms(selectedIds.length > 0 ? selectedIds : ['wechat']);
updatePublishButton();
}
async function loadPendingExtract() {
const { pendingExtract } = await chrome.storage.local.get('pendingExtract');
if (!pendingExtract || Date.now() - Number(pendingExtract.extractedAt || 0) > 24 * 60 * 60 * 1000) return;
applyExtractedData(pendingExtract);
extractStatus.textContent = '已载入最近提取的页面内容';
extractStatus.className = 'status success';
}
async function savePlatformPrefs() {
await chrome.storage.sync.set({ defaultPlatforms: getSelectedPlatforms() });
}
// ===== Extract Page Content =====
async function extractCurrentPage() {
showLoading('正在提取页面内容...');
extractBtn.classList.add('extracting');
extractStatus.textContent = '提取中...';
extractBtn.disabled = true;
extractStatus.textContent = '正在提取页面内容...';
extractStatus.className = 'status loading';
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) throw new Error('无法取当前标签页');
if (!tab?.id) throw new Error('无法取当前标签页');
const response = await chrome.tabs.sendMessage(tab.id, { action: 'extractContent' });
if (!response?.success) throw new Error(response?.error || '页面内容提取失败');
if (response && response.success) {
const data = response.data;
extractedData = data;
titleInput.value = data.title || '';
contentInput.value = data.body || '';
charCount.textContent = contentInput.value.length;
if (data.images && data.images.length > 0) {
setImagePreview(data.images[0]);
} else {
clearImage();
}
showStatus('success', '✓ 页面内容提取成功');
extractStatus.textContent = '✓ 已提取';
updatePublishButton();
} else {
throw new Error(response?.error || '提取失败');
}
} catch (err) {
console.error('Extract error:', err);
showStatus('error', '提取失败: ' + err.message);
extractStatus.textContent = '';
const data = { ...response.data, extractedAt: Date.now() };
applyExtractedData(data);
await chrome.storage.local.set({ pendingExtract: data });
extractStatus.textContent = `已提取正文和 ${Array.isArray(data.images) ? data.images.length : 0} 张图片`;
extractStatus.className = 'status success';
} catch (error) {
extractStatus.textContent = error instanceof Error ? error.message : String(error);
extractStatus.className = 'status error';
} finally {
hideLoading();
extractBtn.classList.remove('extracting');
extractBtn.disabled = false;
}
}
// ===== Image Handling =====
function setImagePreview(url) {
selectedImageUrl = url;
previewImg.src = url;
imagePreview.classList.remove('hidden');
noImage.classList.add('hidden');
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function clearImage() {
selectedImageUrl = null;
previewImg.src = '';
imagePreview.classList.add('hidden');
noImage.classList.remove('hidden');
function plainTextToHtml(value) {
return value
.split(/\n{2,}/)
.map(block => `<p>${block.split('\n').map(escapeHtml).join('<br>')}</p>`)
.join('');
}
// ===== Publish =====
async function publishToPlatforms() {
const platforms = getSelectedPlatforms();
if (platforms.length === 0) {
showStatus('warning', '⚠ 请至少选择一个发布平台');
return;
}
async function preparePlatforms() {
const platformIds = getSelectedPlatforms();
const title = titleInput.value.trim();
const content = contentInput.value.trim();
if (!title && !content) {
showStatus('warning', '⚠ 请输入标题或内容');
return;
}
const plainText = contentInput.value.trim();
if (platformIds.length === 0 || (!title && !plainText)) return;
showLoading('正在发布到 ' + platforms.length + ' 个平台...');
publishBtn.disabled = true;
statusBar.textContent = `正在为 ${platformIds.length} 个平台准备编辑器...`;
statusBar.className = 'status loading';
try {
const settings = await chrome.storage.sync.get({
apiUrl: DEFAULT_API_URL,
apiKey: ''
});
const payload = {
title: title,
content: content,
platforms: platforms,
imageUrl: selectedImageUrl || null,
aiPolish: aiPolish.checked
const images = Array.isArray(extractedData?.images) ? extractedData.images : [];
const html = plainTextToHtml(plainText);
const content = {
source: 'aitoearn',
draftId: `extension-${Date.now()}`,
title,
markdown: plainText,
html,
body: html,
plainText,
coverUrl: images[0],
images,
platforms: platformIds,
options: { saveAsDraft: false },
};
const headers = {
'Content-Type': 'application/json'
};
if (settings.apiKey) {
headers['Authorization'] = 'Bearer ' + settings.apiKey;
}
const response = await fetch(settings.apiUrl + '/v2/channels/publish', {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
const result = await sendRuntimeMessage({
type: PREPARE_BATCH_MESSAGE,
platformIds,
content,
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.message || '发布请求失败 (HTTP ' + response.status + ')');
}
const result = await response.json();
const successCount = result.successCount || platforms.length;
showStatus('success', '✓ 成功发布到 ' + successCount + ' 个平台');
} catch (err) {
console.error('Publish error:', err);
if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) {
showStatus('error', '✗ 无法连接到服务器,请检查后端是否运行');
} else {
showStatus('error', '✗ 发布失败: ' + err.message);
}
statusBar.textContent = result.message;
statusBar.className = result.success ? 'status success' : 'status error';
} catch (error) {
statusBar.textContent = error instanceof Error ? error.message : String(error);
statusBar.className = 'status error';
} finally {
hideLoading();
publishBtn.disabled = false;
updatePublishButton();
}
}
// ===== AI Polish =====
aiPolish.addEventListener('change', async () => {
await saveSettings({ autoPolish: aiPolish.checked });
function setupEventListeners() {
extractBtn.addEventListener('click', extractCurrentPage);
publishBtn.addEventListener('click', preparePlatforms);
settingsBtn.addEventListener('click', () => chrome.runtime.openOptionsPage());
titleInput.addEventListener('input', updatePublishButton);
contentInput.addEventListener('input', () => {
charCount.textContent = String(contentInput.value.length);
updatePublishButton();
});
platformGrid.addEventListener('change', () => {
void savePlatformPrefs();
updatePublishButton();
});
selectAllBtn.addEventListener('click', () => {
document.querySelectorAll('input[name="platform"]').forEach(input => {
input.checked = true;
});
void savePlatformPrefs();
updatePublishButton();
});
selectNoneBtn.addEventListener('click', () => {
document.querySelectorAll('input[name="platform"]').forEach(input => {
input.checked = false;
});
void savePlatformPrefs();
updatePublishButton();
});
}
if (aiPolish.checked && contentInput.value.trim()) {
await polishContent();
document.addEventListener('DOMContentLoaded', async () => {
extensionVersion.textContent = chrome.runtime.getManifest().version;
setupEventListeners();
try {
await Promise.all([loadPlatforms(), loadPendingExtract()]);
} catch (error) {
statusBar.textContent = error instanceof Error ? error.message : String(error);
statusBar.className = 'status error';
}
});
async function polishContent() {
const title = titleInput.value.trim();
const content = contentInput.value.trim();
if (!content) return;
showLoading('AI 正在润色内容...');
try {
const settings = await chrome.storage.sync.get({
apiUrl: DEFAULT_API_URL,
apiKey: ''
});
const headers = { 'Content-Type': 'application/json' };
if (settings.apiKey) {
headers['Authorization'] = 'Bearer ' + settings.apiKey;
}
const response = await fetch(settings.apiUrl + '/ai/drafts', {
method: 'POST',
headers: headers,
body: JSON.stringify({ title, content })
});
if (!response.ok) throw new Error('润色请求失败');
const result = await response.json();
if (result.title) titleInput.value = result.title;
if (result.content) contentInput.value = result.content;
charCount.textContent = contentInput.value.length;
showStatus('success', '✓ AI润色完成');
} catch (err) {
showStatus('error', '✗ 润色失败: ' + err.message);
} finally {
hideLoading();
}
}
// ===== UI Helpers =====
function updatePublishButton() {
const hasContent = titleInput.value.trim() || contentInput.value.trim();
const hasPlatforms = getSelectedPlatforms().length > 0;
publishBtn.disabled = !(hasContent && hasPlatforms);
}
function showStatus(type, message) {
statusBar.className = 'status-bar ' + type;
statusIcon.textContent = '';
statusText.textContent = message;
statusBar.classList.remove('hidden');
clearTimeout(showStatus._timer);
showStatus._timer = setTimeout(() => {
statusBar.classList.add('hidden');
}, 5000);
}
function showLoading(text) {
loadingText.textContent = text || '处理中...';
loadingOverlay.classList.remove('hidden');
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
}
// ===== Start =====
document.addEventListener('DOMContentLoaded', init);
+127
View File
@@ -0,0 +1,127 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$manifestPath = Join-Path $repoRoot 'manifest.json'
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$manifest.version
if ([string]::IsNullOrWhiteSpace($version)) {
throw 'manifest.json does not contain a version'
}
$requiredPaths = @(
'manifest.json',
'background.js',
'content.js',
'distribution-bridge.js',
'distribution-page-bridge.js',
'interaction-bridge.js',
'interaction-page-bridge.js',
'popup.html',
'popup.css',
'popup.js',
'options.html',
'options.css',
'options.js',
'icons',
'distribution',
'interaction'
)
foreach ($relativePath in $requiredPaths) {
$sourcePath = Join-Path $repoRoot $relativePath
if (-not (Test-Path -LiteralPath $sourcePath)) {
throw "Required extension path is missing: $relativePath"
}
}
$javascriptFiles = Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Filter '*.js' |
Where-Object {
$_.FullName -notlike "$(Join-Path $repoRoot '.git')*" -and
$_.FullName -notlike "$(Join-Path $repoRoot 'dist')*"
}
foreach ($javascriptFile in $javascriptFiles) {
& node --check $javascriptFile.FullName
if ($LASTEXITCODE -ne 0) {
throw "JavaScript syntax validation failed: $($javascriptFile.FullName)"
}
}
& node (Join-Path $PSScriptRoot 'test-interaction.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Interaction bridge validation failed'
}
& node (Join-Path $PSScriptRoot 'test-distribution.mjs')
if ($LASTEXITCODE -ne 0) {
throw 'Distribution bridge validation failed'
}
$distRoot = Join-Path $repoRoot 'dist'
$stageRoot = Join-Path $distRoot "aitoearn-extension-v$version"
$archivePath = Join-Path $distRoot "aitoearn-extension-v$version.zip"
New-Item -ItemType Directory -Path $distRoot -Force | Out-Null
foreach ($targetPath in @($stageRoot, $archivePath)) {
$resolvedParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $targetPath))
$resolvedDist = [System.IO.Path]::GetFullPath($distRoot)
if ($resolvedParent -ne $resolvedDist) {
throw "Refusing to replace a path outside dist: $targetPath"
}
if (Test-Path -LiteralPath $targetPath) {
Remove-Item -LiteralPath $targetPath -Recurse -Force
}
}
New-Item -ItemType Directory -Path $stageRoot | Out-Null
foreach ($relativePath in $requiredPaths) {
$sourcePath = Join-Path $repoRoot $relativePath
$destinationPath = Join-Path $stageRoot $relativePath
if ((Get-Item -LiteralPath $sourcePath).PSIsContainer) {
Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Recurse
}
else {
Copy-Item -LiteralPath $sourcePath -Destination $destinationPath
}
}
Compress-Archive -Path (Join-Path $stageRoot '*') -DestinationPath $archivePath -CompressionLevel Optimal
$verificationRoot = Join-Path $distRoot '.verify'
if (Test-Path -LiteralPath $verificationRoot) {
Remove-Item -LiteralPath $verificationRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $verificationRoot | Out-Null
Expand-Archive -LiteralPath $archivePath -DestinationPath $verificationRoot
if (-not (Test-Path -LiteralPath (Join-Path $verificationRoot 'manifest.json'))) {
throw 'Packaged archive does not contain manifest.json at its root'
}
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$archiveStream = $null
try {
$archiveStream = [System.IO.File]::OpenRead($archivePath)
$archiveHashBytes = $sha256.ComputeHash($archiveStream)
}
finally {
if ($null -ne $archiveStream) {
$archiveStream.Dispose()
}
$sha256.Dispose()
}
$archiveHash = -join ($archiveHashBytes | ForEach-Object { $_.ToString('x2') })
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Remove-Item -LiteralPath $verificationRoot -Recurse -Force
Remove-Item -LiteralPath $stageRoot -Recurse -Force
[pscustomobject]@{
Version = $version
Archive = $archivePath
Bytes = $archiveSize
Sha256 = $archiveHash
}
+226
View File
@@ -0,0 +1,226 @@
import assert from 'node:assert/strict'
import {
ToutiaoLoginConfig,
ZhihuLoginConfig,
} from '../distribution/cose/detection/src/configs.js'
import { parseJianshuAccount } from '../distribution/cose/detection/src/platforms/jianshu.js'
import { inspectJianshuTaskState } from '../distribution/cose/core/platforms/jianshu.js'
import { inspectJuejinTaskState } from '../distribution/cose/core/platforms/juejin.js'
import { inspectZhihuTaskState } from '../distribution/cose/core/platforms/zhihu.js'
import {
inspectToutiaoTaskState,
renderToutiaoMarkdown,
} from '../distribution/cose/core/platforms/toutiao.js'
import { canReturnExistingPlatformTaskState } from '../distribution/cose/core/task-state.js'
assert.deepEqual(
inspectJuejinTaskState('https://juejin.cn/editor/drafts/new'),
{
status: 'filled',
url: 'https://juejin.cn/editor/drafts/new',
message: '内容仍在新建编辑器中,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectJuejinTaskState('https://juejin.cn/editor/drafts/123456'),
{
status: 'draft_saved',
url: 'https://juejin.cn/editor/drafts/123456',
platformWorkId: '123456',
message: '已确认掘金草稿地址',
},
)
assert.deepEqual(
inspectJuejinTaskState('https://juejin.cn/post/987654'),
{
status: 'published',
url: 'https://juejin.cn/post/987654',
platformWorkId: '987654',
message: '已确认掘金文章公开地址',
},
)
assert.equal(
inspectJuejinTaskState('https://juejin.cn/creator/content/article').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/editor/drafts/new')),
true,
)
assert.equal(
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/post/987654')),
true,
)
assert.equal(
canReturnExistingPlatformTaskState(inspectJuejinTaskState('https://juejin.cn/creator/content/article')),
false,
)
assert.deepEqual(
parseJianshuAccount({
data: {
id: 12345,
nickname: '简书作者',
avatar: 'https://upload.jianshu.io/avatar.png',
},
}),
{
platformUid: '12345',
username: '简书作者',
avatar: 'https://upload.jianshu.io/avatar.png',
},
)
assert.equal(parseJianshuAccount({ data: { nickname: '无稳定标识' } }).platformUid, undefined)
assert.deepEqual(
inspectJianshuTaskState('https://www.jianshu.com/writer#/notebooks/12/notes/34'),
{
status: 'draft_saved',
url: 'https://www.jianshu.com/writer#/notebooks/12/notes/34',
platformWorkId: '34',
message: '已确认简书草稿地址',
},
)
assert.deepEqual(
inspectJianshuTaskState(
'https://www.jianshu.com/writer#/notebooks/12/notes/34',
'https://www.jianshu.com/p/published-note',
),
{
status: 'published',
url: 'https://www.jianshu.com/p/published-note',
platformWorkId: 'published-note',
message: '已确认简书文章公开地址',
},
)
assert.equal(
inspectJianshuTaskState('https://www.jianshu.com/writer').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectJianshuTaskState('https://www.jianshu.com/writer#/notebooks/12/notes/34'),
),
true,
)
assert.deepEqual(
ZhihuLoginConfig.getUserInfo({
id: 'zhihu-user-1',
name: '知乎作者',
avatar_url: 'https://picx.zhimg.com/avatar.jpg',
}),
{
platformUid: 'zhihu-user-1',
username: '知乎作者',
avatar: 'https://picx.zhimg.com/avatar.jpg',
},
)
assert.equal(
ZhihuLoginConfig.getUserInfo({ name: '无稳定标识' }).platformUid,
undefined,
)
assert.deepEqual(
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write'),
{
status: 'filled',
url: 'https://zhuanlan.zhihu.com/write',
message: '内容已填充到知乎新建编辑器,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write/draft-123'),
{
status: 'draft_saved',
url: 'https://zhuanlan.zhihu.com/write/draft-123',
platformWorkId: 'draft-123',
message: '已确认知乎草稿地址',
},
)
assert.deepEqual(
inspectZhihuTaskState('https://zhuanlan.zhihu.com/p/987654'),
{
status: 'published',
url: 'https://zhuanlan.zhihu.com/p/987654',
platformWorkId: '987654',
message: '已确认知乎文章公开地址',
},
)
assert.equal(
inspectZhihuTaskState('https://www.zhihu.com/creator').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectZhihuTaskState('https://zhuanlan.zhihu.com/write/draft-123'),
),
true,
)
assert.deepEqual(
ToutiaoLoginConfig.getUserInfo({
code: 0,
data: {
user_id: 'toutiao-user-1',
name: '头条作者',
avatar_url: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
},
}),
{
platformUid: 'toutiao-user-1',
username: '头条作者',
avatar: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
},
)
assert.equal(
ToutiaoLoginConfig.checkLogin({ code: 0, data: { name: '无稳定标识' } }),
false,
)
assert.deepEqual(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish'),
{
status: 'filled',
url: 'https://mp.toutiao.com/profile_v4/graphic/publish',
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456'),
{
status: 'draft_saved',
url: 'https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456',
platformWorkId: '123456',
message: '已确认今日头条草稿地址',
},
)
assert.deepEqual(
inspectToutiaoTaskState('https://www.toutiao.com/article/987654321/'),
{
status: 'published',
url: 'https://www.toutiao.com/article/987654321/',
platformWorkId: '987654321',
message: '已确认今日头条文章公开地址',
},
)
assert.equal(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/articles').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draftId=789'),
),
true,
)
assert.match(
renderToutiaoMarkdown('# 标题\n\n- 第一项\n- 第二项\n\n![配图](https://example.com/image.png)\n\n**重点**'),
/<h1>标题<\/h1><ul><li>第一项<\/li><li>第二项<\/li><\/ul><p><img src="https:\/\/example\.com\/image\.png" alt="配图" \/><\/p><p><strong>重点<\/strong><\/p>/,
)
console.log('Distribution handoff tests passed')
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { createInteractionCapabilities } from '../interaction/capabilities.js';
import {
doesDouyinAccountMatch,
executeDouyinReplyRequest,
executeDouyinSessionRequest,
InteractionValidationError,
normalizeDouyinRequest,
} from '../interaction/douyin.js';
assert.deepEqual(
createInteractionCapabilities('1.4.0').platforms.douyin.comments,
{ list: true, replies: true, create: false, manualReply: true },
);
assert.deepEqual(
normalizeDouyinRequest('listDouyinComments', { workId: '7570305000069549352' }),
{ workId: '7570305000069549352', cursor: '0', count: 20 },
);
assert.deepEqual(
normalizeDouyinRequest('replyDouyinComment', {
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: ' 感谢你的反馈 ',
expectedAccountUid: 'MS4wLjABAAAA_test-account',
}),
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '感谢你的反馈',
expectedAccountUid: 'MS4wLjABAAAA_test-account',
},
);
assert.equal(doesDouyinAccountMatch('sec-uid', {
uid: 'sec-uid',
numericUid: '123',
uniqueId: 'account-name',
}), true);
assert.equal(doesDouyinAccountMatch('another-account', {
uid: 'sec-uid',
numericUid: '123',
uniqueId: 'account-name',
}), false);
assert.deepEqual(
normalizeDouyinRequest('listDouyinReplies', {
workId: '7570305000069549352',
commentId: '7570539465648243462',
cursor: 20,
count: 999,
}),
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
cursor: '20',
count: 20,
},
);
for (const payload of [
{ workId: 'https://example.com/' },
{ workId: '7570305000069549352', cursor: '-1' },
{ workId: '7570305000069549352', count: 0 },
]) {
assert.throws(
() => normalizeDouyinRequest('listDouyinComments', payload),
error => error instanceof InteractionValidationError,
);
}
for (const payload of [
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '',
expectedAccountUid: 'sec-uid',
},
{
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: 'x'.repeat(501),
expectedAccountUid: 'sec-uid',
},
{
workId: '7570305000069549352',
commentId: 'not-an-id',
content: 'reply',
expectedAccountUid: 'sec-uid',
},
]) {
assert.throws(
() => normalizeDouyinRequest('replyDouyinComment', payload),
error => error instanceof InteractionValidationError,
);
}
const originalFetch = globalThis.fetch;
try {
const sessionCalls = [];
globalThis.fetch = async (url, options = {}) => {
sessionCalls.push({ url, options });
if (url === '/web/api/media/user/info/') {
return {
ok: true,
status: 200,
json: async () => ({
status_code: 0,
user: {
sec_uid: 'sec-uid',
uid: '123456',
unique_id: 'aitoearn-test',
nickname: 'AiToEarn 测试账号',
avatar_thumb: { url_list: ['https://example.com/avatar.png'] },
},
}),
};
}
if (url === '/web/api/media/anchor/search') {
return {
ok: true,
status: 200,
headers: new Headers({ 'x-ware-csrf-token': 'challenge,csrf-token-value' }),
};
}
throw new Error(`Unexpected session URL: ${url}`);
};
assert.deepEqual(await executeDouyinSessionRequest({ includeCsrf: true }), {
ok: true,
result: {
account: {
uid: 'sec-uid',
numericUid: '123456',
uniqueId: 'aitoearn-test',
nickname: 'AiToEarn 测试账号',
avatarUrl: 'https://example.com/avatar.png',
},
csrfToken: 'csrf-token-value',
},
});
assert.equal(sessionCalls.length, 2);
assert.equal(sessionCalls[1].options.method, 'HEAD');
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Request'], '1');
assert.equal(sessionCalls[1].options.headers['X-Secsdk-Csrf-Version'], '1.2.22');
const replyCalls = [];
globalThis.fetch = async (url, options = {}) => {
replyCalls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({
status_code: 0,
comment: { cid: 'new-comment-id' },
}),
};
};
assert.deepEqual(await executeDouyinReplyRequest({
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '感谢你的反馈',
csrfToken: 'csrf-token-value',
}), {
ok: true,
result: {
platform: 'douyin',
workId: '7570305000069549352',
parentCommentId: '7570539465648243462',
platformCommentId: 'new-comment-id',
success: true,
source: 'douyin-web',
},
});
assert.equal(replyCalls.length, 1);
assert.equal(replyCalls[0].url, '/aweme/v1/web/comment/publish/?aid=6383');
assert.equal(replyCalls[0].options.method, 'POST');
assert.equal(replyCalls[0].options.headers['x-secsdk-csrf-token'], 'csrf-token-value');
assert.equal(replyCalls[0].options.body.get('aweme_id'), '7570305000069549352');
assert.equal(replyCalls[0].options.body.get('reply_id'), '7570539465648243462');
assert.equal(replyCalls[0].options.body.get('text'), '感谢你的反馈');
assert.equal(replyCalls[0].options.body.get('one_level_comment_rank'), '1');
let failedReplyCallCount = 0;
globalThis.fetch = async () => {
failedReplyCallCount += 1;
return {
ok: false,
status: 500,
json: async () => ({}),
};
};
const failedReply = await executeDouyinReplyRequest({
workId: '7570305000069549352',
commentId: '7570539465648243462',
content: '仅发送一次',
csrfToken: 'csrf-token-value',
});
assert.equal(failedReply.ok, false);
assert.equal(failedReply.error.code, 'DOUYIN_REPLY_HTTP_ERROR');
assert.equal(failedReplyCallCount, 1);
} finally {
globalThis.fetch = originalFetch;
}
console.log('Interaction bridge validation passed.');