Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c4fbf6310 | |||
| ef620109cf | |||
| 9a5d299a1a |
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
aitoearn-extension.zip
|
||||
@@ -0,0 +1,55 @@
|
||||
# AiToEarn Chrome Extension
|
||||
|
||||
AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打包发布的扩展源码,用于:
|
||||
|
||||
- 从当前网页采集标题、正文和图片。
|
||||
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
|
||||
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
|
||||
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动读取桥接,不冒充旧官方扩展的完整能力。
|
||||
- 复用浏览器现有登录状态检测内容平台账号。
|
||||
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
|
||||
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
|
||||
|
||||
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
|
||||
|
||||
扩展弹窗和后台通知中的“成功”只表示平台编辑器已打开并完成内容填充,不表示内容已经公开发布。右键菜单“提取此页面到 AiToEarn”会把最近一次提取结果暂存到浏览器本地,随后打开扩展即可继续选择平台。
|
||||
|
||||
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
|
||||
|
||||
当前互动桥接只开放受控白名单:能力查询、抖音评论列表和抖音回复列表。作品 ID、评论 ID、游标和数量均由扩展校验,网页不能向扩展传入任意请求地址。扩展优先复用当前 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
@@ -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
@@ -3,17 +3,47 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (globalThis.__AI_TO_EARN_CONTENT_SCRIPT__) return;
|
||||
globalThis.__AI_TO_EARN_CONTENT_SCRIPT__ = true;
|
||||
|
||||
const contentSelectors = [
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'main',
|
||||
'.amos-land-page',
|
||||
'.post-content',
|
||||
'.article-content',
|
||||
'.entry-content',
|
||||
'.content-body',
|
||||
'.markdown-body',
|
||||
'.rich_media_content',
|
||||
'#article-content',
|
||||
'#content',
|
||||
'.content',
|
||||
'.post',
|
||||
'.article',
|
||||
'.story-body',
|
||||
'#cnblogs_post_body',
|
||||
'.htmledit_views',
|
||||
'#article_content',
|
||||
'.blog-content-box'
|
||||
];
|
||||
|
||||
// ===== Content Extraction =====
|
||||
function extractPageContent() {
|
||||
const data = {
|
||||
title: '',
|
||||
description: '',
|
||||
body: '',
|
||||
html: '',
|
||||
images: [],
|
||||
links: [],
|
||||
url: window.location.href,
|
||||
siteName: '',
|
||||
author: '',
|
||||
publishDate: ''
|
||||
publishDate: '',
|
||||
pageType: 'article',
|
||||
accessState: { status: 'ok', message: '', requiresUserAction: false }
|
||||
};
|
||||
|
||||
// --- Title ---
|
||||
@@ -43,49 +73,32 @@
|
||||
|| '';
|
||||
|
||||
// --- Body Content ---
|
||||
data.body = extractMainContent();
|
||||
const mainContent = extractMainContent();
|
||||
data.body = mainContent.text.slice(0, 100000);
|
||||
data.html = mainContent.html.slice(0, 900000);
|
||||
|
||||
// --- Images ---
|
||||
data.images = extractImages();
|
||||
data.images = extractImages(mainContent.element);
|
||||
data.links = extractLinks(mainContent.element);
|
||||
data.pageType = detectPageType();
|
||||
data.accessState = detectAccessState(data.body);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getMetaContent(name) {
|
||||
const el = document.querySelector(meta[property=""], meta[name=""]);
|
||||
const el = document.querySelector(`meta[property="${name}"], meta[name="${name}"]`);
|
||||
return el?.content || '';
|
||||
}
|
||||
|
||||
// ===== Main Content Extraction =====
|
||||
function extractMainContent() {
|
||||
// Priority selectors for main content areas
|
||||
const contentSelectors = [
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'main',
|
||||
'.post-content',
|
||||
'.article-content',
|
||||
'.entry-content',
|
||||
'.content-body',
|
||||
'.markdown-body',
|
||||
'#article-content',
|
||||
'#content',
|
||||
'.content',
|
||||
'.post',
|
||||
'.article',
|
||||
'.story-body',
|
||||
'#cnblogs_post_body',
|
||||
'.htmledit_views',
|
||||
'#article_content',
|
||||
'.blog-content-box'
|
||||
];
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
const text = cleanText(el.innerText);
|
||||
if (text.length > 50) {
|
||||
return text;
|
||||
return { text, html: el.outerHTML, element: el };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,16 +110,66 @@
|
||||
.map(p => p.textContent.trim())
|
||||
.filter(t => t.length > 20);
|
||||
if (texts.length > 0) {
|
||||
return cleanText(texts.join('\n\n'));
|
||||
return {
|
||||
text: cleanText(texts.join('\n\n')),
|
||||
html: `<main>${Array.from(paragraphs).map(p => p.outerHTML).join('')}</main>`,
|
||||
element: document.body
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return cleanText(document.body.innerText);
|
||||
return {
|
||||
text: cleanText(document.body?.innerText || ''),
|
||||
html: document.body?.innerHTML || '',
|
||||
element: document.body
|
||||
};
|
||||
}
|
||||
|
||||
function detectPageType() {
|
||||
const route = `${window.location.pathname}${window.location.search}`.toLowerCase();
|
||||
if (/\/trending\/|\/topic(?:\/|$)|topic_innerflow/.test(route)
|
||||
|| document.querySelector('.amos-land-page, [data-page-type="topic"]')) {
|
||||
return 'topic';
|
||||
}
|
||||
if (/\/(?:search|tag|category|hot|feed)(?:\/|$)/.test(window.location.pathname.toLowerCase())
|
||||
|| document.querySelector('[data-page-type="listing"]')) {
|
||||
return 'listing';
|
||||
}
|
||||
return 'article';
|
||||
}
|
||||
|
||||
function detectAccessState(extractedBody) {
|
||||
const visible = element => Boolean(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length));
|
||||
const pageText = cleanText(document.body?.innerText || '').slice(0, 12000);
|
||||
const lowerText = pageText.toLowerCase();
|
||||
const captchaElement = Array.from(document.querySelectorAll(
|
||||
'[class*="captcha"], [id*="captcha"], [class*="verify"], [id*="verify"], iframe[src*="captcha"]'
|
||||
)).find(visible);
|
||||
if (captchaElement || /(验证码|安全验证|人机验证|滑块验证|verify you are human|complete the security check|captcha)/i.test(pageText)) {
|
||||
return { status: 'captcha_required', message: '页面需要完成验证码或安全验证', requiresUserAction: true };
|
||||
}
|
||||
|
||||
const passwordInput = Array.from(document.querySelectorAll('input[type="password"]')).find(visible);
|
||||
const loginRoute = /\/(?:login|signin|passport)(?:\/|$)/i.test(window.location.pathname);
|
||||
if (passwordInput || (loginRoute && /(登录|登陆|sign in|log in)/i.test(pageText))) {
|
||||
return { status: 'login_required', message: '页面需要登录后才能采集', requiresUserAction: true };
|
||||
}
|
||||
|
||||
if (extractedBody.length < 120 && (
|
||||
lowerText.includes('access denied')
|
||||
|| lowerText.includes('forbidden')
|
||||
|| lowerText.includes('not available in your region')
|
||||
|| /(所在地区|当前地区|地区限制|需要.*vpn|网络环境受限)/i.test(pageText)
|
||||
)) {
|
||||
return { status: 'access_restricted', message: '页面存在地区或网络访问限制', requiresUserAction: true };
|
||||
}
|
||||
|
||||
return { status: 'ok', message: '', requiresUserAction: false };
|
||||
}
|
||||
|
||||
// ===== Image Extraction =====
|
||||
function extractImages() {
|
||||
function extractImages(mainElement) {
|
||||
const images = new Set();
|
||||
|
||||
// 1. Open Graph image
|
||||
@@ -121,18 +184,17 @@
|
||||
// Skip favicons, they're not useful as content images
|
||||
|
||||
// 4. Content area images (from article/main/content areas)
|
||||
const contentSelectors = ['article', 'main', '.content', '.post-content', '#content'];
|
||||
for (const selector of contentSelectors) {
|
||||
const container = document.querySelector(selector);
|
||||
if (container) {
|
||||
container.querySelectorAll('img').forEach(img => {
|
||||
const src = img.src || img.dataset.src || img.dataset.original;
|
||||
if (src && !isSmallImage(img) && !isIconOrAvatar(src)) {
|
||||
images.add(resolveUrl(src));
|
||||
}
|
||||
});
|
||||
if (images.size > 0) break;
|
||||
}
|
||||
if (mainElement) {
|
||||
mainElement.querySelectorAll('img').forEach(img => {
|
||||
const src = img.currentSrc
|
||||
|| img.src
|
||||
|| img.dataset.src
|
||||
|| img.dataset.original
|
||||
|| img.dataset.lazySrc;
|
||||
if (src && !isSmallImage(img) && !isIconOrAvatar(src)) {
|
||||
images.add(resolveUrl(src));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. If no content images found, scan all images
|
||||
@@ -148,6 +210,15 @@
|
||||
return Array.from(images).slice(0, 10);
|
||||
}
|
||||
|
||||
function extractLinks(mainElement) {
|
||||
const links = new Set();
|
||||
(mainElement || document.body)?.querySelectorAll('a[href]').forEach(anchor => {
|
||||
const href = resolveUrl(anchor.getAttribute('href'));
|
||||
if (/^https?:\/\//i.test(href)) links.add(href);
|
||||
});
|
||||
return Array.from(links).slice(0, 100);
|
||||
}
|
||||
|
||||
function isSmallImage(img) {
|
||||
return (img.naturalWidth > 0 && img.naturalWidth < 100) ||
|
||||
(img.naturalHeight > 0 && img.naturalHeight < 100);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
(function bridgeAiToEarnDistribution() {
|
||||
const requestSource = 'aitoearn-web';
|
||||
const responseSource = 'aitoearn-extension';
|
||||
const allowedMethods = new Set([
|
||||
'getVersion',
|
||||
'listPlatforms',
|
||||
'checkPlatforms',
|
||||
'captureUrl',
|
||||
'startBatch',
|
||||
'syncToPlatform',
|
||||
]);
|
||||
|
||||
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 '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,
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`不支持的分发方法: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (event.source !== window || event.data?.source !== requestSource) return;
|
||||
const { requestId, method, payload } = event.data;
|
||||
if (!requestId || !allowedMethods.has(method)) return;
|
||||
|
||||
try {
|
||||
const result = await handle(method, payload);
|
||||
window.postMessage({ source: responseSource, requestId, result }, '*');
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
source: responseSource,
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
(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 }),
|
||||
captureUrl: url => request('captureUrl', { url }),
|
||||
startBatch: () => request('startBatch'),
|
||||
syncToPlatform: (platformId, content) => request('syncToPlatform', { platformId, content }),
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('aitoearn:distribution-ready'));
|
||||
})();
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,10 @@
|
||||
# COSE upstream
|
||||
|
||||
The files under this directory are adapted from
|
||||
[`doocs/cose`](https://github.com/doocs/cose) at commit
|
||||
`e70fa9e92a71cd2f10e0c883981f324a332162d4`.
|
||||
|
||||
The upstream Apache License 2.0 is preserved in `LICENSE`. AiToEarn owns the
|
||||
browser bridge, product UI, payload contract, packaging, and release process;
|
||||
the platform fillers and login detectors remain isolated here so future
|
||||
upstream updates can be reviewed and applied without replacing the extension.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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'
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,122 @@
|
||||
// 平台配置汇总
|
||||
// 从 @cose/detection 导入登录检测配置
|
||||
import { LOGIN_CHECK_CONFIG } from '../../detection/index.js'
|
||||
|
||||
// 平台元数据和同步函数从各平台文件导入
|
||||
import { CSDNPlatform, syncCSDNContent } from './csdn.js'
|
||||
import { JuejinPlatform, syncJuejinContent } from './juejin.js'
|
||||
import { WechatPlatform, syncWechatContent } from './wechat.js'
|
||||
import { ZhihuPlatform, syncZhihuContent } from './zhihu.js'
|
||||
import { ToutiaoPlatform, syncToutiaoContent } from './toutiao.js'
|
||||
import { SegmentFaultPlatform } from './segmentfault.js'
|
||||
import { CnblogsPlatform } from './cnblogs.js'
|
||||
import { OSChinaPlatform } from './oschina.js'
|
||||
import { CTO51Platform } from './cto51.js'
|
||||
import { InfoQPlatform } from './infoq.js'
|
||||
import { JianshuPlatform } from './jianshu.js'
|
||||
import { BaijiahaoPlatform } from './baijiahao.js'
|
||||
import { WangyihaoPlatform, syncWangyihaoContent } from './wangyihao.js'
|
||||
import { TencentCloudPlatform } from './tencentcloud.js'
|
||||
import { 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,
|
||||
wechat: syncWechatContent,
|
||||
zhihu: syncZhihuContent,
|
||||
toutiao: syncToutiaoContent,
|
||||
wangyihao: syncWangyihaoContent,
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS, getPlatformFiller }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,59 @@
|
||||
// 简书平台配置
|
||||
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',
|
||||
}
|
||||
|
||||
// 简书内容填充函数
|
||||
async function fillJianshuContent(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
const contentToFill = markdown || body || ''
|
||||
|
||||
// 填充标题 - 简书使用 input._24i7u,需要使用 native setter
|
||||
const titleInput = await waitFor('input._24i7u, input[class*="title"]')
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
const inputSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
inputSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
console.log('[COSE] 简书标题填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 简书未找到标题输入框')
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 简书使用 textarea#arthur-editor 作为 Markdown 编辑器
|
||||
const editor =
|
||||
document.querySelector('#arthur-editor') || document.querySelector('textarea._3swFR')
|
||||
if (editor) {
|
||||
editor.focus()
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
textareaSetter.call(editor, contentToFill)
|
||||
editor.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: contentToFill, inputType: 'insertText' })
|
||||
)
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 简书内容填充成功')
|
||||
} else {
|
||||
console.log('[COSE] 简书未找到编辑器')
|
||||
}
|
||||
}
|
||||
|
||||
export { JianshuPlatform, fillJianshuContent }
|
||||
@@ -0,0 +1,89 @@
|
||||
// 掘金平台配置
|
||||
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'
|
||||
|
||||
// 掘金内容填充函数(在页面主世界中执行)
|
||||
// 注意:需要先调用 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, 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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,142 @@
|
||||
// 今日头条平台配置
|
||||
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',
|
||||
}
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 今日头条内容填充函数(在页面主世界中执行)
|
||||
function fillToutiaoContentInPage(title, body) {
|
||||
// 等待满足条件的元素出现
|
||||
function waitForElement(predicate, timeout = 10000) {
|
||||
return new Promise(resolve => {
|
||||
const el = predicate()
|
||||
if (el) return resolve(el)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = predicate()
|
||||
if (el) {
|
||||
observer.disconnect()
|
||||
resolve(el)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(predicate())
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
async function fillContent() {
|
||||
// 填充标题 - 头条使用 textarea
|
||||
const titleInput = await waitForElement(() =>
|
||||
document.querySelector('textarea[placeholder*="标题"]')
|
||||
)
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 模拟用户输入
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
nativeSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(
|
||||
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
|
||||
)
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
console.log('[COSE] 头条标题填充成功:', title)
|
||||
} else {
|
||||
console.log('[COSE] 头条未找到标题输入框')
|
||||
}
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 头条使用 ProseMirror 富文本编辑器
|
||||
const editor = await waitForElement(() => document.querySelector('.ProseMirror'))
|
||||
|
||||
if (editor && body) {
|
||||
editor.focus()
|
||||
|
||||
// 对于 ProseMirror,我们需要更智能的方式来填充内容
|
||||
// 清空现有内容
|
||||
editor.innerHTML = ''
|
||||
|
||||
// 将内容分割成段落
|
||||
const lines = body.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
// 使用 document.execCommand 插入内容(ProseMirror 兼容)
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
// 创建 HTML 内容
|
||||
const htmlContent = lines.map(line => `<p>${line}</p>`).join('')
|
||||
|
||||
// 使用 insertHTML 命令
|
||||
document.execCommand('insertHTML', false, htmlContent)
|
||||
|
||||
// 触发事件让 ProseMirror 同步
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
console.log('[COSE] 头条内容填充成功')
|
||||
return { success: true }
|
||||
} else {
|
||||
console.log('[COSE] 头条未找到编辑器')
|
||||
return { success: false, error: '未找到编辑器' }
|
||||
}
|
||||
}
|
||||
|
||||
return fillContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日头条同步处理器
|
||||
* @param {object} tab - Chrome tab 对象
|
||||
* @param {object} content - 内容对象 { title, body, markdown }
|
||||
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
|
||||
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
|
||||
*/
|
||||
async function syncToutiaoContent(tab, content, helpers) {
|
||||
const { chrome, waitForTab } = helpers
|
||||
|
||||
// 等待页面加载完成
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 额外等待一下让编辑器完全加载
|
||||
await new Promise(resolve => setTimeout(resolve, 2500))
|
||||
|
||||
// 先注入公共工具函数
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 在页面中执行填充
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillToutiaoContentInPage,
|
||||
args: [content.title, content.body || content.markdown || ''],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
const fillResult = result?.[0]?.result
|
||||
if (fillResult?.success) {
|
||||
return { success: true, message: '已打开头条号并填充内容', tabId: tab.id }
|
||||
} else {
|
||||
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { ToutiaoPlatform, fillToutiaoContentInPage, syncToutiaoContent }
|
||||
@@ -0,0 +1,276 @@
|
||||
// Twitter Articles 平台配置
|
||||
// 使用 marked 进行 Markdown 解析,并转换为 Twitter Articles 支持的格式
|
||||
|
||||
const TwitterPlatform = {
|
||||
id: 'twitter',
|
||||
name: 'Twitter',
|
||||
icon: 'https://abs.twimg.com/favicons/twitter.3.ico',
|
||||
url: 'https://x.com',
|
||||
publishUrl: 'https://x.com/compose/articles/edit/',
|
||||
title: 'Twitter Articles',
|
||||
type: 'twitter',
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 Markdown 渲染器
|
||||
* 将 Markdown 转换为 Twitter Articles 支持的 HTML 格式
|
||||
*/
|
||||
function createTwitterRenderer(marked) {
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
// 标题转换 - Twitter Articles 支持 h1, h2, h3
|
||||
renderer.heading = function (text, level) {
|
||||
// Twitter Articles 使用 h1 作为标题,h2 作为副标题
|
||||
// 文章内容中的标题映射:# -> h2, ## -> h3, ### -> h4
|
||||
const mappedLevel = Math.min(level + 1, 4)
|
||||
return `<h${mappedLevel}>${text}</h${mappedLevel}>\n`
|
||||
}
|
||||
|
||||
// 段落
|
||||
renderer.paragraph = function (text) {
|
||||
return `<p>${text}</p>\n`
|
||||
}
|
||||
|
||||
// 粗体
|
||||
renderer.strong = function (text) {
|
||||
return `<strong>${text}</strong>`
|
||||
}
|
||||
|
||||
// 斜体
|
||||
renderer.em = function (text) {
|
||||
return `<em>${text}</em>`
|
||||
}
|
||||
|
||||
// 删除线
|
||||
renderer.del = function (text) {
|
||||
return `<s>${text}</s>`
|
||||
}
|
||||
|
||||
// 代码块 - Twitter Articles 不原生支持代码高亮,使用 pre + code 格式
|
||||
renderer.code = function (code, language) {
|
||||
// 转义 HTML 特殊字符
|
||||
const escapedCode = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
// 使用带样式的 pre 标签,模拟代码块效果
|
||||
return `<pre style="background-color: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 14px; line-height: 1.45;"><code>${escapedCode}</code></pre>\n`
|
||||
}
|
||||
|
||||
// 行内代码
|
||||
renderer.codespan = function (code) {
|
||||
const escapedCode = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<code style="background-color: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-family: 'SF Mono', Consolas, monospace; font-size: 0.9em;">${escapedCode}</code>`
|
||||
}
|
||||
|
||||
// 引用块
|
||||
renderer.blockquote = function (quote) {
|
||||
return `<blockquote style="border-left: 4px solid #1d9bf0; padding-left: 16px; margin: 16px 0; color: #536471;">${quote}</blockquote>\n`
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
renderer.list = function (body, ordered) {
|
||||
const tag = ordered ? 'ol' : 'ul'
|
||||
return `<${tag}>${body}</${tag}>\n`
|
||||
}
|
||||
|
||||
// 列表项
|
||||
renderer.listitem = function (text) {
|
||||
return `<li>${text}</li>\n`
|
||||
}
|
||||
|
||||
// 链接
|
||||
renderer.link = function (href, title, text) {
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
}
|
||||
|
||||
// 图片
|
||||
renderer.image = function (href, title, text) {
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
const altAttr = text ? ` alt="${text}"` : ''
|
||||
return `<img src="${href}"${altAttr}${titleAttr} style="max-width: 100%; height: auto;" />`
|
||||
}
|
||||
|
||||
// 水平分割线
|
||||
renderer.hr = function () {
|
||||
return `<hr style="border: none; border-top: 1px solid #cfd9de; margin: 24px 0;" />\n`
|
||||
}
|
||||
|
||||
// 表格
|
||||
renderer.table = function (header, body) {
|
||||
return `<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
|
||||
<thead>${header}</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>\n`
|
||||
}
|
||||
|
||||
renderer.tablerow = function (content) {
|
||||
return `<tr>${content}</tr>\n`
|
||||
}
|
||||
|
||||
renderer.tablecell = function (content, flags) {
|
||||
const tag = flags.header ? 'th' : 'td'
|
||||
const align = flags.align
|
||||
? ` style="text-align: ${flags.align}; border: 1px solid #cfd9de; padding: 8px;"`
|
||||
: ' style="border: 1px solid #cfd9de; padding: 8px;"'
|
||||
return `<${tag}${align}>${content}</${tag}>\n`
|
||||
}
|
||||
|
||||
return renderer
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理数学公式
|
||||
* 将 LaTeX 公式转换为可显示的格式
|
||||
* Twitter Articles 不原生支持 LaTeX,这里转换为图片或文本格式
|
||||
*/
|
||||
function processLatexFormulas(markdown) {
|
||||
// 处理行内公式 $...$
|
||||
let processed = markdown.replace(/\$([^\$\n]+)\$/g, (match, formula) => {
|
||||
// 使用 CodeCogs API 将 LaTeX 转换为图片
|
||||
const encodedFormula = encodeURIComponent(formula.trim())
|
||||
return `<img src="https://latex.codecogs.com/svg.image?${encodedFormula}" alt="${formula}" style="vertical-align: middle;" />`
|
||||
})
|
||||
|
||||
// 处理块级公式 $$...$$
|
||||
processed = processed.replace(/\$\$([^\$]+)\$\$/g, (match, formula) => {
|
||||
const encodedFormula = encodeURIComponent(formula.trim())
|
||||
return `<div style="text-align: center; margin: 16px 0;"><img src="https://latex.codecogs.com/svg.image?${encodedFormula}" alt="${formula}" /></div>`
|
||||
})
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 转换为 Twitter Articles 支持的 HTML
|
||||
* @param {string} markdown - 原始 Markdown 内容
|
||||
* @param {object} marked - marked 库实例
|
||||
* @returns {string} - 转换后的 HTML
|
||||
*/
|
||||
function convertMarkdownToTwitterHtml(markdown, marked) {
|
||||
if (!markdown) return ''
|
||||
|
||||
// 先处理 LaTeX 公式
|
||||
let processedMarkdown = processLatexFormulas(markdown)
|
||||
|
||||
// 配置 marked
|
||||
const renderer = createTwitterRenderer(marked)
|
||||
marked.setOptions({
|
||||
renderer: renderer,
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
pedantic: false,
|
||||
})
|
||||
|
||||
// 转换 Markdown 为 HTML
|
||||
const html = marked.parse(processedMarkdown)
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitter Articles 内容填充函数
|
||||
* 流程:
|
||||
* 1. 等待编辑器加载
|
||||
* 2. 使用 marked 解析 Markdown 并转换格式
|
||||
* 3. 填充标题到 textarea[placeholder="Add a title"]
|
||||
* 4. 通过 paste 事件填充 HTML 内容到 Draft.js 编辑器
|
||||
*/
|
||||
async function fillTwitterContent(content, waitFor, setInputValue) {
|
||||
const { title, body, markdown } = content
|
||||
|
||||
console.log('[COSE] Twitter Articles 开始同步...')
|
||||
|
||||
// 等待编辑器加载
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// 动态加载 marked 库
|
||||
let marked
|
||||
try {
|
||||
// 尝试从 CDN 加载 marked
|
||||
if (!window.marked) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js'
|
||||
document.head.appendChild(script)
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
setTimeout(reject, 5000) // 5秒超时
|
||||
})
|
||||
}
|
||||
marked = window.marked
|
||||
} catch (e) {
|
||||
console.error('[COSE] 加载 marked 库失败:', e)
|
||||
// 降级处理:直接使用原始内容
|
||||
marked = null
|
||||
}
|
||||
|
||||
// 转换 Markdown 为 Twitter Articles HTML
|
||||
let htmlContent
|
||||
if (marked && markdown) {
|
||||
htmlContent = convertMarkdownToTwitterHtml(markdown, marked)
|
||||
console.log('[COSE] Markdown 已转换为 HTML')
|
||||
} else {
|
||||
// 降级:使用原始 body 或简单转换
|
||||
htmlContent = body || markdown || ''
|
||||
if (!htmlContent.includes('<')) {
|
||||
// 如果是纯文本,简单转换为段落
|
||||
htmlContent = htmlContent
|
||||
.split('\n\n')
|
||||
.map(p => `<p>${p}</p>`)
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// 第一步:填充标题
|
||||
// Twitter Articles 使用 textarea[placeholder="Add a title"]
|
||||
const titleInput = await waitFor(
|
||||
'textarea[placeholder="Add a title"], textarea[name="Article Title"]',
|
||||
5000
|
||||
)
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 使用 native setter 来绑定 React 的受控组件
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
).set
|
||||
nativeSetter.call(titleInput, title)
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] Twitter Articles 标题填充成功')
|
||||
}
|
||||
|
||||
// 第二步:填充内容
|
||||
// Twitter Articles 使用 Draft.js 编辑器
|
||||
const contentEl = await waitFor(
|
||||
'.public-DraftEditor-content[contenteditable="true"], .DraftEditor-root [contenteditable="true"]',
|
||||
5000
|
||||
)
|
||||
if (contentEl && htmlContent) {
|
||||
contentEl.focus()
|
||||
|
||||
// 创建 DataTransfer 并设置 HTML 内容
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/html', htmlContent)
|
||||
dt.setData('text/plain', htmlContent.replace(/<[^>]*>/g, ''))
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
contentEl.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] Twitter Articles 内容填充成功 (Draft.js)')
|
||||
} else {
|
||||
console.log('[COSE] Twitter Articles 未找到内容编辑器')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { TwitterPlatform, fillTwitterContent, convertMarkdownToTwitterHtml }
|
||||
@@ -0,0 +1,13 @@
|
||||
// 火山引擎开发者社区平台配置
|
||||
const VolcenginePlatform = {
|
||||
id: 'volcengine',
|
||||
name: 'Volcengine',
|
||||
icon: 'https://lf1-cdn-tos.bytegoofy.com/goofy/tech-fe/fav.png',
|
||||
url: 'https://developer.volcengine.com/',
|
||||
publishUrl: 'https://developer.volcengine.com/articles/draft',
|
||||
title: '火山引擎开发者社区',
|
||||
type: 'volcengine',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { VolcenginePlatform }
|
||||
@@ -0,0 +1,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 }
|
||||
@@ -0,0 +1,418 @@
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 微信公众号平台配置
|
||||
const WechatPlatform = {
|
||||
id: 'wechat',
|
||||
name: 'WeChat',
|
||||
icon: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico',
|
||||
url: 'https://mp.weixin.qq.com',
|
||||
// 先打开草稿箱,再自动点击新建
|
||||
publishUrl:
|
||||
'https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10',
|
||||
title: '微信公众号',
|
||||
type: 'wechat',
|
||||
}
|
||||
|
||||
function getEditorArea(editor) {
|
||||
return (editor?.clientHeight || 0) * (editor?.clientWidth || 0)
|
||||
}
|
||||
|
||||
function isWechatTitleEditor(editor, titleEditor) {
|
||||
return (
|
||||
Boolean(editor) && (editor === titleEditor || Boolean(editor.closest?.('.title-editor__input')))
|
||||
)
|
||||
}
|
||||
|
||||
function pickWechatBodyProseMirrorCandidate(nodes, { titleInput, titleEditor } = {}) {
|
||||
const bodyCandidates = nodes.filter(editor => !isWechatTitleEditor(editor, titleEditor))
|
||||
if (bodyCandidates.length === 0) return null
|
||||
if (bodyCandidates.length === 1) return bodyCandidates[0]
|
||||
|
||||
const byPlaceholder = bodyCandidates.find(editor =>
|
||||
(editor.textContent || '').includes('从这里开始写正文')
|
||||
)
|
||||
if (byPlaceholder) return byPlaceholder
|
||||
|
||||
if (titleInput) {
|
||||
const band = titleInput.getBoundingClientRect()
|
||||
const belowTitle = bodyCandidates.filter(editor => {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
return rect.top >= band.bottom - 8
|
||||
})
|
||||
if (belowTitle.length > 0) {
|
||||
return belowTitle.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
}
|
||||
|
||||
return bodyCandidates.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
|
||||
// 微信公众号内容填充函数(在页面主世界中执行)
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
async function fillWechatContent(title, htmlBody) {
|
||||
/**
|
||||
* 后台改版后可能存在多个 `.ProseMirror`(标题区也可能是 ProseMirror),
|
||||
* `querySelector('.ProseMirror')` 常会命中标题编辑器,导致正文 HTML 被贴进标题。
|
||||
* 另外,正文编辑器有时会比标题编辑器晚挂载,这时也要继续等待,不能把唯一节点误判成正文。
|
||||
*/
|
||||
function pickWechatBodyProseMirror() {
|
||||
// 内联辅助函数,确保 chrome.scripting.executeScript 注入时可用
|
||||
function getEditorArea(editor) {
|
||||
return (editor?.clientHeight || 0) * (editor?.clientWidth || 0)
|
||||
}
|
||||
function isWechatTitleEditor(editor, titleEditor) {
|
||||
return (
|
||||
Boolean(editor) &&
|
||||
(editor === titleEditor || Boolean(editor.closest?.('.title-editor__input')))
|
||||
)
|
||||
}
|
||||
function pickCandidate(nodes, { titleInput, titleEditor } = {}) {
|
||||
const bodyCandidates = nodes.filter(editor => !isWechatTitleEditor(editor, titleEditor))
|
||||
if (bodyCandidates.length === 0) return null
|
||||
if (bodyCandidates.length === 1) return bodyCandidates[0]
|
||||
|
||||
const byPlaceholder = bodyCandidates.find(editor =>
|
||||
(editor.textContent || '').includes('从这里开始写正文')
|
||||
)
|
||||
if (byPlaceholder) return byPlaceholder
|
||||
|
||||
if (titleInput) {
|
||||
const band = titleInput.getBoundingClientRect()
|
||||
const belowTitle = bodyCandidates.filter(editor => {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
return rect.top >= band.bottom - 8
|
||||
})
|
||||
if (belowTitle.length > 0) {
|
||||
return belowTitle.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
}
|
||||
|
||||
return bodyCandidates.sort((a, b) => getEditorArea(b) - getEditorArea(a))[0]
|
||||
}
|
||||
|
||||
const nodes = [...document.querySelectorAll('.ProseMirror')]
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const titleInput = document.querySelector('#title')
|
||||
const titleEditor = document.querySelector('.title-editor__input .ProseMirror')
|
||||
return pickCandidate(nodes, { titleInput, titleEditor })
|
||||
}
|
||||
|
||||
async function waitForBodyEditor(timeout = 15000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const el = pickWechatBodyProseMirror()
|
||||
if (el) return el
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
return pickWechatBodyProseMirror()
|
||||
}
|
||||
|
||||
try {
|
||||
const titleInput = await window.waitFor('#title', 15000)
|
||||
const titleEditor = await window.waitFor('.title-editor__input .ProseMirror', 15000)
|
||||
|
||||
// 填充标题(优先于正文,避免焦点停留在标题区的 ProseMirror)
|
||||
if ((titleInput || titleEditor) && title) {
|
||||
if (titleEditor) {
|
||||
titleEditor.focus()
|
||||
titleEditor.innerHTML = ''
|
||||
titleEditor.textContent = title
|
||||
titleEditor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleEditor.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
if (titleInput) {
|
||||
titleInput.focus()
|
||||
}
|
||||
const nativeSetter =
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set ||
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (titleInput && nativeSetter) {
|
||||
nativeSetter.call(titleInput, title)
|
||||
} else if (titleInput) {
|
||||
titleInput.value = title
|
||||
}
|
||||
if (titleInput) {
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
console.log('[COSE] 微信标题已填充:', title)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
const editor = await waitForBodyEditor(15000)
|
||||
if (!editor) {
|
||||
return { success: false, error: '未找到正文编辑器' }
|
||||
}
|
||||
|
||||
// 填充正文内容
|
||||
if (editor && htmlBody) {
|
||||
editor.focus()
|
||||
|
||||
// 清空现有占位符内容
|
||||
if (editor.textContent.includes('从这里开始写正文')) {
|
||||
editor.innerHTML = ''
|
||||
}
|
||||
|
||||
const plainText = htmlBody.replace(/<[^>]*>/g, '')
|
||||
const hasImageInSource = /<img\b/i.test(htmlBody)
|
||||
let injected = false
|
||||
let injectError = ''
|
||||
|
||||
// 优先使用微信编辑器 JSAPI。合成 Ctrl+V 事件不会触发真实粘贴;
|
||||
// 直接写 innerHTML 也不会可靠同步到 ProseMirror 的文档模型。
|
||||
if (window.__MP_Editor_JSAPI__ && typeof window.__MP_Editor_JSAPI__.invoke === 'function') {
|
||||
injected = await new Promise(resolve => {
|
||||
let done = false
|
||||
const finish = (ok, err) => {
|
||||
if (done) return
|
||||
done = true
|
||||
if (err) injectError = err.message || String(err)
|
||||
resolve(ok)
|
||||
}
|
||||
|
||||
try {
|
||||
window.__MP_Editor_JSAPI__.invoke({
|
||||
apiName: 'mp_editor_set_content',
|
||||
apiParam: { content: htmlBody },
|
||||
sucCb: () => finish(true),
|
||||
errCb: err => finish(false, err),
|
||||
})
|
||||
} catch (err) {
|
||||
finish(false, err)
|
||||
}
|
||||
|
||||
setTimeout(() => finish(false, new Error('mp_editor_set_content 调用超时')), 5000)
|
||||
})
|
||||
|
||||
if (injected) {
|
||||
console.log('[COSE] 微信内容已通过 mp_editor_set_content 注入')
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
} else {
|
||||
console.warn('[COSE] mp_editor_set_content 注入失败:', injectError)
|
||||
}
|
||||
}
|
||||
|
||||
if (!injected) {
|
||||
if (hasImageInSource) {
|
||||
console.warn('[COSE] 正文包含图片,但微信 JSAPI 不可用;paste 兜底可能无法保留图片')
|
||||
}
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/html', htmlBody)
|
||||
dt.setData('text/plain', plainText)
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
editor.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] 微信内容已通过 paste 事件注入(兜底方案)')
|
||||
|
||||
// 等待内容渲染
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
}
|
||||
|
||||
// 验证内容是否注入成功
|
||||
const wordCount = editor.textContent?.trim().length || 0
|
||||
const imageCount = editor.querySelectorAll?.('img').length || 0
|
||||
const hasEditorContent = wordCount > 0 || imageCount > 0 || injected
|
||||
|
||||
return {
|
||||
success: hasEditorContent,
|
||||
error: hasEditorContent ? undefined : injectError || '正文注入后未检测到有效内容',
|
||||
wordCount,
|
||||
imageCount,
|
||||
titleFilled: titleInput?.value === title || titleEditor?.textContent?.trim() === title,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: '内容为空' }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 微信公众号保存草稿函数(在页面主世界中执行)
|
||||
function saveWechatDraft() {
|
||||
const saveDraftBtn = Array.from(document.querySelectorAll('button')).find(b =>
|
||||
b.textContent.includes('保存为草稿')
|
||||
)
|
||||
if (saveDraftBtn) {
|
||||
saveDraftBtn.click()
|
||||
console.log('[COSE] 已点击保存为草稿')
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: '未找到保存按钮' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信公众号同步处理器
|
||||
* @param {object} tab - Chrome tab 对象(初始为首页)
|
||||
* @param {object} content - 内容对象 { title, body, markdown, wechatHtml }
|
||||
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup, PLATFORMS }
|
||||
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
|
||||
*/
|
||||
async function syncWechatContent(tab, content, helpers) {
|
||||
const { chrome, waitForTab } = helpers
|
||||
|
||||
// 步骤1:等待首页加载完成
|
||||
console.log('[COSE] 微信公众号等待页面加载')
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 注入公共工具函数(waitFor, setInputValue)
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 步骤2:使用 MutationObserver 监听获取 token
|
||||
console.log('[COSE] 开始检测 token...')
|
||||
const [tokenResult] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
return new Promise(resolve => {
|
||||
// 先检查当前页面是否已有 token
|
||||
const checkToken = () => {
|
||||
const urlMatch = window.location.href.match(/token=(\d+)/)
|
||||
if (urlMatch) return urlMatch[1]
|
||||
|
||||
const links = document.querySelectorAll('a[href*="token"]')
|
||||
for (const link of links) {
|
||||
const match = link.href?.match(/token=(\d+)/)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
const scripts = document.querySelectorAll('script:not([src])')
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent
|
||||
const match = content.match(/token["']?\s*[:=]\s*["']?(\d+)["']?/i)
|
||||
if (match && match[1]) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = checkToken()
|
||||
if (existing) return resolve(existing)
|
||||
|
||||
// 使用 MutationObserver 监听 DOM 变化
|
||||
const observer = new MutationObserver(() => {
|
||||
const token = checkToken()
|
||||
if (token) {
|
||||
observer.disconnect()
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
|
||||
// 超时保护
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(checkToken())
|
||||
}, 10000)
|
||||
})
|
||||
},
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
const token = tokenResult?.result
|
||||
|
||||
if (!token) {
|
||||
console.error('[COSE] 无法从页面获取 token')
|
||||
return { success: false, message: '无法获取微信公众号 token,请确保已登录', tabId: tab.id }
|
||||
}
|
||||
|
||||
// 步骤3:跳转到编辑器页面
|
||||
const editorUrl = `https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10&token=${token}&lang=zh_CN`
|
||||
console.log('[COSE] 获取到 token:', token, '跳转到编辑器')
|
||||
|
||||
await chrome.tabs.update(tab.id, { url: editorUrl })
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 使用剪贴板 HTML(带完整样式)或降级到 body
|
||||
const htmlContent = content.wechatHtml || content.html || content.body || ''
|
||||
console.log('[COSE] 微信 HTML 内容长度:', htmlContent?.length || 0)
|
||||
|
||||
// 步骤4:使用 MutationObserver 监听编辑器出现
|
||||
console.log('[COSE] 正在等待编辑器...')
|
||||
const [editorResult] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
return new Promise(resolve => {
|
||||
const existing = document.querySelector('.ProseMirror')
|
||||
if (existing) return resolve(true)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelector('.ProseMirror')) {
|
||||
observer.disconnect()
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(!!document.querySelector('.ProseMirror'))
|
||||
}, 15000)
|
||||
})
|
||||
},
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
if (!editorResult?.result) {
|
||||
console.error('[COSE] 编辑器等待超时')
|
||||
return { success: false, message: '编辑器加载超时', tabId: tab.id }
|
||||
}
|
||||
|
||||
console.log('[COSE] 编辑器已就绪,开始注入内容...')
|
||||
|
||||
// 页面跳转后需要重新注入工具函数(waitFor, setInputValue)
|
||||
await injectUtils(chrome, tab.id)
|
||||
|
||||
// 步骤5:填充内容
|
||||
let result
|
||||
try {
|
||||
result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillWechatContent,
|
||||
args: [content.title, htmlContent],
|
||||
world: 'MAIN',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[COSE] executeScript 执行失败:', e)
|
||||
return { success: false, message: '脚本执行失败: ' + e.message, tabId: tab.id }
|
||||
}
|
||||
|
||||
const fillResult = result?.[0]?.result
|
||||
console.log('[COSE] 微信填充结果:', JSON.stringify(fillResult, null, 2))
|
||||
|
||||
if (!fillResult?.success) {
|
||||
console.error('[COSE] 微信内容填充失败:', fillResult?.error)
|
||||
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
|
||||
}
|
||||
|
||||
console.log('[COSE] 微信内容填充成功,字数:', fillResult.wordCount)
|
||||
|
||||
if (content.options?.saveAsDraft === true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const saveResult = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: saveWechatDraft,
|
||||
world: 'MAIN',
|
||||
})
|
||||
if (!saveResult?.[0]?.result?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: saveResult?.[0]?.result?.error || '内容已填充,但保存微信草稿失败',
|
||||
tabId: tab.id,
|
||||
}
|
||||
}
|
||||
return { success: true, message: '已填充并保存到微信草稿箱', tabId: tab.id }
|
||||
}
|
||||
|
||||
return { success: true, message: '已填充到微信公众号编辑器,请检查后手动保存', tabId: tab.id }
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { WechatPlatform, fillWechatContent, pickWechatBodyProseMirrorCandidate, syncWechatContent }
|
||||
@@ -0,0 +1,13 @@
|
||||
// 微博头条文章平台配置
|
||||
const WeiboPlatform = {
|
||||
id: 'weibo',
|
||||
name: 'Weibo',
|
||||
icon: 'https://weibo.com/favicon.ico',
|
||||
url: 'https://weibo.com',
|
||||
publishUrl: 'https://card.weibo.com/article/v5/editor#/draft',
|
||||
title: '微博头条',
|
||||
type: 'weibo',
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { WeiboPlatform }
|
||||
@@ -0,0 +1,20 @@
|
||||
// 小红书平台配置
|
||||
// 同步方式:使用剪贴板 HTML 粘贴到编辑器
|
||||
const XiaohongshuPlatform = {
|
||||
id: 'xiaohongshu',
|
||||
name: 'Xiaohongshu',
|
||||
icon: 'https://www.xiaohongshu.com/favicon.ico',
|
||||
url: 'https://creator.xiaohongshu.com',
|
||||
publishUrl: 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=article',
|
||||
title: '小红书',
|
||||
type: 'xiaohongshu',
|
||||
}
|
||||
|
||||
// 小红书内容填充函数(由 background.js 处理)
|
||||
// 使用剪贴板粘贴方式填充内容
|
||||
async function fillXiaohongshuContent(content, waitFor, setInputValue) {
|
||||
console.log('[COSE] 小红书填充由 background.js 处理')
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { XiaohongshuPlatform, fillXiaohongshuContent }
|
||||
@@ -0,0 +1,434 @@
|
||||
// 知乎平台配置
|
||||
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',
|
||||
}
|
||||
|
||||
import { injectUtils } from './common.js'
|
||||
|
||||
// 知乎内容填充函数(在页面主世界中执行)
|
||||
// 知乎现在支持直接粘贴 Markdown,然后弹窗提示转换
|
||||
// 注意:需要先调用 injectUtils 注入 window.waitFor
|
||||
function fillZhihuContent(title, markdown) {
|
||||
// 等待满足条件的元素出现(使用 MutationObserver)
|
||||
function waitForElement(predicate, timeout = 10000) {
|
||||
return new Promise(resolve => {
|
||||
const el = predicate()
|
||||
if (el) return resolve(el)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = predicate()
|
||||
if (el) {
|
||||
observer.disconnect()
|
||||
resolve(el)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(predicate())
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// 等待按钮出现并点击
|
||||
async function waitAndClickButton(textMatcher, timeout = 5000) {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const buttons = document.querySelectorAll('button')
|
||||
for (const btn of buttons) {
|
||||
if (textMatcher(btn.textContent)) {
|
||||
btn.click()
|
||||
console.log('[COSE] 已点击按钮:', btn.textContent)
|
||||
return true
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fillContent() {
|
||||
// 第一步:等待知乎编辑器完全加载(避免"草稿加载中"提示)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 第二步:填充标题
|
||||
async function fillTitle() {
|
||||
const titleInput = await window.waitFor('textarea[placeholder*="标题"]')
|
||||
if (titleInput && title) {
|
||||
titleInput.focus()
|
||||
// 使用 nativeInputValueSetter 确保 React 识别变更
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(titleInput, title)
|
||||
} else {
|
||||
titleInput.value = title
|
||||
}
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
console.log('[COSE] 知乎标题填充成功')
|
||||
}
|
||||
}
|
||||
|
||||
// 先填充标题
|
||||
await fillTitle()
|
||||
|
||||
// 再等待一下确保标题已保存
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 第三步:找到并激活知乎编辑器
|
||||
const editorSelectors = [
|
||||
'.public-DraftEditor-content',
|
||||
'[contenteditable="true"]',
|
||||
'.DraftEditor-root',
|
||||
]
|
||||
|
||||
let editor = null
|
||||
for (const selector of editorSelectors) {
|
||||
editor = document.querySelector(selector)
|
||||
if (editor) break
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
console.log('[COSE] 未找到知乎编辑器')
|
||||
return { success: false, error: 'Editor not found' }
|
||||
}
|
||||
|
||||
// 激活编辑器:模拟真实点击序列
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
// 触发鼠标事件序列激活编辑器
|
||||
for (const eventType of ['mousedown', 'mouseup', 'click']) {
|
||||
const event = new MouseEvent(eventType, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: centerX,
|
||||
clientY: centerY,
|
||||
button: 0,
|
||||
})
|
||||
editor.dispatchEvent(event)
|
||||
}
|
||||
|
||||
// 聚焦编辑器
|
||||
editor.focus()
|
||||
|
||||
// 清空现有内容
|
||||
document.execCommand('selectAll', false)
|
||||
document.execCommand('delete', false)
|
||||
|
||||
// 等待编辑器状态更新
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 第三步:通过剪贴板 + 键盘事件模拟真实粘贴
|
||||
// 这是触发知乎 Markdown 检测弹窗的关键方法
|
||||
const contentToFill = markdown || ''
|
||||
|
||||
if (!contentToFill) {
|
||||
console.log('[COSE] 没有 Markdown 内容需要填充')
|
||||
await fillTitle()
|
||||
return { success: true, method: 'empty' }
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ClipboardEvent 模拟粘贴 - 这是触发 Markdown 检测弹窗的关键
|
||||
// execCommand('insertText') 不会触发弹窗
|
||||
|
||||
// 检查浏览器兼容性
|
||||
if (typeof DataTransfer === 'undefined' || typeof ClipboardEvent === 'undefined') {
|
||||
throw new Error('浏览器不支持 DataTransfer 或 ClipboardEvent')
|
||||
}
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/plain', contentToFill)
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
const dispatched = editor.dispatchEvent(pasteEvent)
|
||||
console.log('[COSE] 已触发 ClipboardEvent,dispatched:', dispatched)
|
||||
|
||||
// 等待 Markdown 检测弹窗出现并点击"确认并解析"
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const parseClicked = await waitAndClickButton(text => text.includes('确认并解析'), 5000)
|
||||
|
||||
if (parseClicked) {
|
||||
console.log('[COSE] 已点击"确认并解析"')
|
||||
|
||||
// 等待解析完成并点击"确认"
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const confirmClicked = await waitAndClickButton(text => text === '确认', 5000)
|
||||
|
||||
if (confirmClicked) {
|
||||
console.log('[COSE] 已点击"确认",Markdown 解析完成')
|
||||
}
|
||||
} else {
|
||||
console.log('[COSE] 未检测到 Markdown 弹窗')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[COSE] 内容插入失败:', err.message || err)
|
||||
}
|
||||
|
||||
// 等待内容渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
return { success: true, method: 'paste-markdown' }
|
||||
}
|
||||
|
||||
return fillContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 知乎同步处理器
|
||||
* 知乎现在支持直接粘贴 Markdown,然后弹窗提示转换
|
||||
* @param {object} tab - Chrome tab 对象
|
||||
* @param {object} content - 内容对象 { title, body, markdown }
|
||||
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
|
||||
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
|
||||
*/
|
||||
async function syncZhihuContent(tab, content, helpers) {
|
||||
const { waitForTab } = helpers
|
||||
|
||||
// 等待页面加载完成(waitForTab 使用 chrome.tabs.onUpdated 监听)
|
||||
await waitForTab(tab.id)
|
||||
|
||||
// 激活知乎标签页(避免后台标签页限制导致填充失败)
|
||||
try {
|
||||
await chrome.tabs.update(tab.id, { active: true })
|
||||
console.log('[COSE] 已激活知乎标签页')
|
||||
// 等待标签页激活完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
} catch (err) {
|
||||
console.log('[COSE] 激活标签页失败:', err.message || err)
|
||||
}
|
||||
|
||||
// 先注入公共工具函数(waitFor 使用 MutationObserver)
|
||||
await injectUtils(globalThis.chrome, tab.id)
|
||||
|
||||
// 在页面中执行内容填充
|
||||
const result = await globalThis.chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: fillZhihuContent,
|
||||
args: [content.title, content.markdown],
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
const fillResult = result?.[0]?.result
|
||||
if (fillResult?.success) {
|
||||
// 等待 2 秒确保内容已保存
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 等待图片上传完成后再刷新
|
||||
console.log('[COSE] 开始监听图片上传请求...')
|
||||
const uploadComplete = await waitForImageUploadComplete(tab.id)
|
||||
|
||||
if (uploadComplete) {
|
||||
console.log('[COSE] 图片上传完成,准备刷新页面')
|
||||
try {
|
||||
if (chrome?.tabs && tab?.id) {
|
||||
await chrome.tabs.reload(tab.id, { bypassCache: false })
|
||||
console.log('[COSE] 已模拟用户刷新知乎页面')
|
||||
} else {
|
||||
console.log('[COSE] chrome.tabs 或 tab.id 不可用,跳过刷新')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[COSE] 刷新页面失败:', err.message || err)
|
||||
}
|
||||
} else {
|
||||
console.log('[COSE] 未检测到图片上传请求或超时,跳过刷新')
|
||||
}
|
||||
|
||||
return { success: true, message: '已打开知乎并同步内容', tabId: tab.id }
|
||||
} else {
|
||||
return { success: false, message: fillResult?.error || '内容同步失败', tabId: tab.id }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待图片上传完成
|
||||
* @param {number} tabId - 标签页 ID
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function waitForImageUploadComplete(tabId, timeout = 30000) {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 在页面中注入监听脚本
|
||||
const result = await globalThis.chrome.scripting.executeScript({
|
||||
target: { tabId: tabId },
|
||||
func: () => {
|
||||
return new Promise(resolve => {
|
||||
const pendingUploads = new Map() // uploadId -> { url, completed }
|
||||
let hasUploadRequests = false
|
||||
let lastUploadTime = 0
|
||||
|
||||
// 检查是否所有上传都完成
|
||||
const checkAllComplete = () => {
|
||||
if (pendingUploads.size === 0) return false
|
||||
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (!info.completed) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 监听 fetch 请求
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = function (...args) {
|
||||
const url = args[0]
|
||||
const options = args[1] || {}
|
||||
|
||||
// 检测图片上传请求(知乎的图片上传通常包含这些特征)
|
||||
const isImageUpload =
|
||||
typeof url === 'string' &&
|
||||
(url.includes('/api/v4/images') ||
|
||||
url.includes('/api/v4/upload') ||
|
||||
url.includes('upload') ||
|
||||
(options.method === 'POST' && url.includes('zhihu.com')))
|
||||
|
||||
if (isImageUpload) {
|
||||
hasUploadRequests = true
|
||||
lastUploadTime = Date.now()
|
||||
const uploadId = Date.now() + Math.random()
|
||||
pendingUploads.set(uploadId, { url, completed: false })
|
||||
console.log('[COSE] 检测到图片上传请求:', url, uploadId)
|
||||
}
|
||||
|
||||
return originalFetch
|
||||
.apply(this, args)
|
||||
.then(response => {
|
||||
if (isImageUpload) {
|
||||
console.log('[COSE] 图片上传请求完成:', url, response.status)
|
||||
// 标记为已完成
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (info.url === url) {
|
||||
info.completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
})
|
||||
.catch(error => {
|
||||
if (isImageUpload) {
|
||||
console.log('[COSE] 图片上传请求失败:', url, error)
|
||||
// 即使失败也标记为已完成(有反馈结果)
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (info.url === url) {
|
||||
info.completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
// 监听 XMLHttpRequest
|
||||
const originalOpen = XMLHttpRequest.prototype.open
|
||||
const originalSend = XMLHttpRequest.prototype.send
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
this._url = url
|
||||
this._method = method
|
||||
return originalOpen.apply(this, [method, url, ...rest])
|
||||
}
|
||||
|
||||
XMLHttpRequest.prototype.send = function (...args) {
|
||||
const isImageUpload =
|
||||
this._url &&
|
||||
(this._url.includes('/api/v4/images') ||
|
||||
this._url.includes('/api/v4/upload') ||
|
||||
this._url.includes('upload') ||
|
||||
(this._method === 'POST' && this._url.includes('zhihu.com')))
|
||||
|
||||
if (isImageUpload) {
|
||||
hasUploadRequests = true
|
||||
lastUploadTime = Date.now()
|
||||
const uploadId = Date.now() + Math.random()
|
||||
pendingUploads.set(uploadId, { url: this._url, completed: false })
|
||||
console.log('[COSE] 检测到图片上传 XHR:', this._url, uploadId)
|
||||
|
||||
this.addEventListener('loadend', () => {
|
||||
console.log('[COSE] 图片上传 XHR 完成:', this._url, this.status)
|
||||
// 标记为已完成
|
||||
for (const [id, info] of pendingUploads) {
|
||||
if (info.url === this._url) {
|
||||
info.completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return originalSend.apply(this, args)
|
||||
}
|
||||
|
||||
// 定期检查是否所有上传都完成
|
||||
const checkTimer = setInterval(() => {
|
||||
// 如果没有检测到任何上传请求,说明可能没有图片需要上传
|
||||
if (!hasUploadRequests) {
|
||||
console.log('[COSE] 未检测到图片上传请求')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果所有上传都完成,并且距离最后一个上传请求已经过去2秒(确保没有新请求)
|
||||
if (checkAllComplete() && Date.now() - lastUploadTime > 2000) {
|
||||
console.log('[COSE] 所有图片上传请求已完成')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 10秒后如果没有检测到上传请求,认为没有图片需要上传
|
||||
setTimeout(() => {
|
||||
if (!hasUploadRequests) {
|
||||
console.log('[COSE] 10秒内未检测到上传请求,认为无图片')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true)
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
// 超时后无论如何都返回
|
||||
setTimeout(() => {
|
||||
console.log('[COSE] 等待图片上传超时')
|
||||
clearInterval(checkTimer)
|
||||
resolve(true) // 即使超时也刷新
|
||||
}, timeout)
|
||||
})
|
||||
},
|
||||
world: 'MAIN',
|
||||
})
|
||||
|
||||
// 等待监听结果
|
||||
const uploadResult = result?.[0]?.result
|
||||
console.log('[COSE] 图片上传监听结果:', uploadResult)
|
||||
|
||||
// 给一个额外的缓冲时间,确保图片已经完全加载和渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
return uploadResult !== false
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { ZhihuPlatform, fillZhihuContent, syncZhihuContent }
|
||||
@@ -0,0 +1,70 @@
|
||||
// 通用平台工具函数
|
||||
|
||||
/**
|
||||
* 注入通用工具函数到页面主世界
|
||||
* 此函数会在页面中定义 window.waitFor 和 window.setInputValue
|
||||
*/
|
||||
function injectCommonUtils() {
|
||||
// 等待元素出现的工具函数(使用 MutationObserver)
|
||||
window.waitFor = (selector, timeout = 10000) => {
|
||||
return new Promise(resolve => {
|
||||
const el = document.querySelector(selector)
|
||||
if (el) return resolve(el)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = document.querySelector(selector)
|
||||
if (el) {
|
||||
observer.disconnect()
|
||||
resolve(el)
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(document.querySelector(selector))
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// 设置输入值的工具函数
|
||||
window.setInputValue = (el, value) => {
|
||||
if (!el || !value) return
|
||||
el.focus()
|
||||
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
||||
// 使用 native setter 确保 React/Vue 等框架能检测到变化
|
||||
const nativeSetter =
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set ||
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(el, value)
|
||||
} else {
|
||||
el.value = value
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
} else if (el.contentEditable === 'true') {
|
||||
el.innerHTML = value.replace(/\n/g, '<br>')
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 在页面中注入通用工具函数
|
||||
* @param {object} chrome - Chrome API 对象
|
||||
* @param {number} tabId - 目标 tab ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function injectUtils(chrome, tabId) {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: injectCommonUtils,
|
||||
world: 'MAIN',
|
||||
})
|
||||
}
|
||||
|
||||
// 导出
|
||||
export { injectCommonUtils, injectUtils }
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './src/configs.js'
|
||||
export * from './src/detect.js'
|
||||
export * from './src/utils.js'
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @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 => ({
|
||||
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 => ({
|
||||
username: response?.name,
|
||||
avatar: response?.avatar_url,
|
||||
}),
|
||||
}
|
||||
|
||||
// 头条号
|
||||
export const ToutiaoLoginConfig = {
|
||||
api: 'https://mp.toutiao.com/mp/agw/creator_center/user_info?app_id=1231',
|
||||
method: 'GET',
|
||||
checkLogin: response => response?.code === 0 && response?.name,
|
||||
getUserInfo: response => ({
|
||||
username: response?.name,
|
||||
avatar: response?.avatar_url,
|
||||
}),
|
||||
}
|
||||
|
||||
// 百家号
|
||||
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,
|
||||
}
|
||||
@@ -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,33 @@
|
||||
import { convertAvatarToBase64 } from '../utils.js'
|
||||
|
||||
/**
|
||||
* 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()
|
||||
if (!json?.data) return { loggedIn: false }
|
||||
|
||||
const username = json.data.nickname || ''
|
||||
let avatar = json.data.avatar || ''
|
||||
|
||||
if (avatar && avatar.includes('jianshu.io')) {
|
||||
avatar = await convertAvatarToBase64(avatar, 'https://www.jianshu.com/')
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Convert an avatar URL to a base64 data URL to bypass CORS/ORB blocking.
|
||||
* The service worker can fetch with a custom Referer header.
|
||||
* @param {string} avatarUrl - The original avatar URL
|
||||
* @param {string} referer - The Referer header to use for the fetch
|
||||
* @returns {Promise<string>} - base64 data URL, or original URL if conversion fails
|
||||
*/
|
||||
export async function convertAvatarToBase64(avatarUrl, referer) {
|
||||
try {
|
||||
const imgResp = await fetch(avatarUrl, {
|
||||
headers: { Referer: referer },
|
||||
})
|
||||
if (imgResp.ok) {
|
||||
const blob = await imgResp.blob()
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
const base64 = btoa(binary)
|
||||
const mime = blob.type || 'image/jpeg'
|
||||
return `data:${mime};base64,${base64}`
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[COSE] avatar base64 conversion failed:', e.message)
|
||||
}
|
||||
return avatarUrl
|
||||
}
|
||||
|
||||
export async function logToStorage(msg, data = null) {
|
||||
if (data) {
|
||||
console.log(msg, data)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 Cookie 检测登录状态
|
||||
export async function checkLoginByCookie(platformId, config) {
|
||||
try {
|
||||
// 直接按名称查找 cookie
|
||||
const cookieMap = {}
|
||||
if (config.cookieNames) {
|
||||
for (const name of config.cookieNames) {
|
||||
const cookie = await chrome.cookies.get({
|
||||
url: config.cookieUrl || `https://${config.cookieDomain}`,
|
||||
name: name,
|
||||
})
|
||||
if (cookie) {
|
||||
cookieMap[name] = cookie.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[COSE] ${platformId} 找到的cookies:`, Object.keys(cookieMap))
|
||||
|
||||
const hasLoginCookie = config.cookieNames && config.cookieNames.some(name => cookieMap[name])
|
||||
|
||||
if (!hasLoginCookie) {
|
||||
console.log(`[COSE] ${platformId} 未找到登录 cookie`)
|
||||
return { loggedIn: false }
|
||||
}
|
||||
|
||||
// 自定义 cookie 值检测逻辑(如 InfoQ)
|
||||
if (config.customCheck && config.checkCookieValue) {
|
||||
console.log(`[COSE] ${platformId} 使用自定义 cookie 检测`)
|
||||
const result = config.checkCookieValue(cookieMap)
|
||||
console.log(`[COSE] ${platformId} 自定义检测结果:`, result)
|
||||
return result
|
||||
}
|
||||
|
||||
let username = ''
|
||||
let avatar = ''
|
||||
|
||||
// 如果配置了从 cookie 获取用户名
|
||||
if (config.getUsernameFromCookie && config.usernameCookie) {
|
||||
username = decodeURIComponent(cookieMap[config.usernameCookie] || '')
|
||||
}
|
||||
|
||||
// 使用平台配置的 fetchAvatar 回调获取头像
|
||||
if (config.fetchAvatar && typeof config.fetchAvatar === 'function') {
|
||||
try {
|
||||
const fetchedAvatar = await config.fetchAvatar(cookieMap)
|
||||
if (fetchedAvatar) {
|
||||
avatar = fetchedAvatar
|
||||
console.log(`[COSE] ${platformId} 找到头像:`, avatar)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[COSE] ${platformId} 获取头像失败:`, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return { loggedIn: true, username, avatar }
|
||||
} catch (e) {
|
||||
console.log(`[COSE] ${platformId} Cookie 检测失败:`, e.message)
|
||||
return { loggedIn: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 通用 API 检测逻辑
|
||||
export async function detectByApi(platformId, config) {
|
||||
try {
|
||||
console.log(`[COSE] ${platformId} 开始 API 检测: ${config.api}`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000)
|
||||
|
||||
// Collect cookies via chrome.cookies.getAll for MV3 service worker compatibility
|
||||
let cookieStr = ''
|
||||
try {
|
||||
const apiUrl = new URL(config.api)
|
||||
const domain = apiUrl.hostname.split('.').slice(-2).join('.')
|
||||
const domainCookies = await chrome.cookies.getAll({ domain: `.${domain}` })
|
||||
const urlCookies = await chrome.cookies.getAll({ url: config.api })
|
||||
const allCookies = [...domainCookies, ...urlCookies]
|
||||
const seen = new Set()
|
||||
const uniqueCookies = allCookies.filter(c => {
|
||||
const key = `${c.name}=${c.value}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
cookieStr = uniqueCookies.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
} catch (e) {
|
||||
console.log(`[COSE] ${platformId} cookie 收集失败:`, e.message)
|
||||
}
|
||||
|
||||
const apiUrl = new URL(config.api)
|
||||
const origin = apiUrl.origin
|
||||
|
||||
const fetchOptions = {
|
||||
method: config.method || 'GET',
|
||||
headers: {
|
||||
Accept: config.isHtml
|
||||
? 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
...(cookieStr ? { Cookie: cookieStr } : {}),
|
||||
Origin: origin,
|
||||
Referer: origin + '/',
|
||||
...(config.headers || {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
|
||||
if (config.body) {
|
||||
fetchOptions.body = config.body
|
||||
}
|
||||
|
||||
const response = await fetch(config.api, fetchOptions)
|
||||
clearTimeout(timeoutId)
|
||||
console.log(`[COSE] ${platformId} API 响应状态: ${response.status}`)
|
||||
|
||||
let data = null
|
||||
if (config.isHtml) {
|
||||
// 明确指定为 HTML 响应时,使用 text() 解析
|
||||
try {
|
||||
data = await response.text()
|
||||
} catch (e) {
|
||||
data = ''
|
||||
}
|
||||
} else {
|
||||
// 其他情况尝试 JSON 解析
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (e) {
|
||||
data = null
|
||||
}
|
||||
}
|
||||
// console.log(`[COSE] ${platformId} API 数据:`, data)
|
||||
|
||||
const loggedIn = config.checkLogin(data)
|
||||
// console.log(`[COSE] ${platformId} checkLogin 结果: ${loggedIn}`)
|
||||
if (loggedIn && config.getUserInfo) {
|
||||
const userInfo = config.getUserInfo(data)
|
||||
console.log(`[COSE] ${platformId} 用户信息:`, userInfo)
|
||||
return { loggedIn: true, ...userInfo }
|
||||
}
|
||||
return { loggedIn: !!loggedIn }
|
||||
} catch (error) {
|
||||
console.log(`[COSE] ${platformId} API 检测失败: ${error.message}`)
|
||||
return { loggedIn: false, error: error.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>COSE Offscreen</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="./offscreen.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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 }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,49 @@
|
||||
(function bridgeAiToEarnInteraction() {
|
||||
const requestSource = 'aitoearn-interaction-web';
|
||||
const responseSource = 'aitoearn-interaction-extension';
|
||||
const allowedMethods = new Set([
|
||||
'getCapabilities',
|
||||
'listDouyinComments',
|
||||
'listDouyinReplies',
|
||||
]);
|
||||
|
||||
async function handle(method, payload) {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'AITO_EARN_INTERACTION',
|
||||
method,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!response?.success) {
|
||||
const error = new Error(response?.error?.message || 'AiToEarn 互动扩展请求失败');
|
||||
error.code = response?.error?.code || 'INTERACTION_REQUEST_FAILED';
|
||||
throw error;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (
|
||||
event.source !== window
|
||||
|| event.origin !== window.location.origin
|
||||
|| event.data?.source !== requestSource
|
||||
) return;
|
||||
|
||||
const { requestId, method, payload } = event.data;
|
||||
if (!requestId || !allowedMethods.has(method)) return;
|
||||
|
||||
try {
|
||||
const result = await handle(method, payload);
|
||||
window.postMessage({ source: responseSource, requestId, result }, window.location.origin);
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
source: responseSource,
|
||||
requestId,
|
||||
error: {
|
||||
code: error?.code || 'INTERACTION_REQUEST_FAILED',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}, window.location.origin);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,65 @@
|
||||
(function installAiToEarnInteractionBridge() {
|
||||
if (window.AIToEarnInteraction) return;
|
||||
|
||||
const requestSource = 'aitoearn-interaction-web';
|
||||
const responseSource = 'aitoearn-interaction-extension';
|
||||
const pending = new Map();
|
||||
|
||||
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 timeout = window.setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
const error = new Error('AiToEarn 互动扩展响应超时');
|
||||
error.code = 'INTERACTION_RESPONSE_TIMEOUT';
|
||||
reject(error);
|
||||
}, 60000);
|
||||
|
||||
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'),
|
||||
listDouyinComments: payload => request('listDouyinComments', payload),
|
||||
listDouyinReplies: payload => request('listDouyinReplies', payload),
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('aitoearn:interaction-ready'));
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
executeDouyinCommentRequest,
|
||||
InteractionValidationError,
|
||||
normalizeDouyinRequest,
|
||||
} from './douyin.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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCapabilities() {
|
||||
return {
|
||||
version: chrome.runtime.getManifest().version,
|
||||
source: 'aitoearn-interaction',
|
||||
platforms: {
|
||||
douyin: {
|
||||
comments: {
|
||||
list: true,
|
||||
replies: true,
|
||||
create: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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', 'listDouyinComments', 'listDouyinReplies'].includes(method)) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: { code: 'INTERACTION_METHOD_DENIED', message: '不支持的互动扩展方法' },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
Promise.resolve(method === 'getCapabilities'
|
||||
? getCapabilities()
|
||||
: listDouyinComments(method, request.payload))
|
||||
.then(result => sendResponse({ success: true, result }))
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: {
|
||||
code: error?.code || (error instanceof InteractionValidationError
|
||||
? 'INVALID_INTERACTION_PAYLOAD'
|
||||
: 'INTERACTION_REQUEST_FAILED'),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
const ID_PATTERN = /^\d{1,32}$/;
|
||||
const CURSOR_PATTERN = /^\d{1,16}$/;
|
||||
const DEFAULT_COUNT = 20;
|
||||
const MAX_COUNT = 20;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function normalizeDouyinRequest(method, payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new InteractionValidationError('INVALID_INTERACTION_PAYLOAD', '互动请求参数格式不正确');
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
workId: normalizeId(payload.workId, '作品 ID'),
|
||||
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
@@ -1,32 +1,101 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AiToEarn - 内容营销助手",
|
||||
"version": "1.0.0",
|
||||
"description": "智能内容提取与多平台一键发布助手,助力内容营销效率提升",
|
||||
"default_locale": "zh_CN",
|
||||
"version": "1.3.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
@@ -28,7 +28,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
|
||||
.btn-icon:hover{color:var(--text-primary)}
|
||||
.btn-ghost{background:transparent;color:var(--text-muted)}
|
||||
.btn-ghost:hover{color:var(--text-primary)}
|
||||
.platform-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.platform-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;max-height:420px;overflow-y:auto;padding-right:4px}
|
||||
.platform-item{cursor:pointer}
|
||||
.platform-item input{display:none}
|
||||
.platform-tag{display:flex;align-items:center;justify-content:center;padding:8px 6px;border-radius:var(--radius-sm);background:var(--bg-primary);border:1px solid var(--border-color);font-size:12px;font-weight:500;color:var(--text-secondary);transition:all var(--transition);user-select:none}
|
||||
@@ -61,4 +61,4 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
|
||||
.about-card{text-align:center;padding:20px}
|
||||
.about-text{font-size:13px;color:var(--text-secondary)}
|
||||
.about-sub{font-size:12px;color:var(--text-muted);margin-top:4px}
|
||||
.hidden{display:none!important}
|
||||
.hidden{display:none!important}
|
||||
|
||||
+14
-99
@@ -1,126 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AiToEarn 设置</title>
|
||||
<title>AiToEarn 扩展设置</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="options-container">
|
||||
<!-- Header -->
|
||||
<main class="options-container">
|
||||
<header class="options-header">
|
||||
<div class="header-left">
|
||||
<img src="icons/icon48.svg" alt="AiToEarn" class="logo">
|
||||
<img src="icons/icon48.png" alt="" class="logo">
|
||||
<div>
|
||||
<h1>AiToEarn 设置</h1>
|
||||
<p class="subtitle">配置后端连接与默认行为</p>
|
||||
<h1>AiToEarn 扩展设置</h1>
|
||||
<p class="subtitle">选择扩展弹窗默认勾选的平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- API Configuration -->
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
API 连接配置
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="apiUrl">服务器地址</label>
|
||||
<div class="input-row">
|
||||
<input type="url" id="apiUrl" class="input" placeholder="http://localhost:3002" value="">
|
||||
<button id="testConnection" class="btn btn-secondary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
<p class="help-text">AiToEarn 后端 API 服务地址,默认为 http://localhost:3002</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="apiKey">API 密钥</label>
|
||||
<div class="input-row">
|
||||
<input type="password" id="apiKey" class="input" placeholder="输入 API 密钥(可选)">
|
||||
<button id="toggleKeyVisibility" class="btn btn-icon" title="显示/隐藏密钥">
|
||||
<svg id="eyeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="help-text">用于身份验证的 API 密钥,留空则不使用</p>
|
||||
</div>
|
||||
|
||||
<div id="connectionStatus" class="connection-status hidden"></div>
|
||||
<h2 class="card-title">默认目标平台</h2>
|
||||
<div id="platformGrid" class="platform-grid" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- Default Behavior -->
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
默认行为
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">默认发布平台</label>
|
||||
<div class="platform-grid options-platforms">
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="wechat"><span class="platform-tag wechat">微信公众号</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="douyin"><span class="platform-tag douyin">抖音</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="xiaohongshu"><span class="platform-tag xiaohongshu">小红书</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="bilibili"><span class="platform-tag bilibili">B站</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="zhihu"><span class="platform-tag zhihu">知乎</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="juejin"><span class="platform-tag juejin">掘金</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="csdn"><span class="platform-tag csdn">CSDN</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="cnblogs"><span class="platform-tag cnblogs">博客园</span></label>
|
||||
<label class="platform-item"><input type="checkbox" name="defaultPlatform" value="twitter"><span class="platform-tag twitter">Twitter</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
<span>默认开启 AI 润色</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="defaultAutoPolish">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="help-text" style="margin-top: 6px;">开启后每次提取内容将自动进行 AI 润色</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="card actions-card">
|
||||
<div class="actions-row">
|
||||
<button id="saveBtn" class="btn btn-primary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
保存设置
|
||||
</button>
|
||||
<button id="resetBtn" class="btn btn-ghost">恢复默认</button>
|
||||
<button id="saveBtn" class="btn btn-primary" type="button">保存设置</button>
|
||||
<button id="resetBtn" class="btn btn-ghost" type="button">恢复默认</button>
|
||||
</div>
|
||||
<div id="saveStatus" class="save-status hidden"></div>
|
||||
<div id="saveStatus" class="save-status hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="card about-card">
|
||||
<p class="about-text">AiToEarn 内容营销助手 v1.0.0</p>
|
||||
<p class="about-sub">智能内容提取 · AI润色 · 多平台一键发布</p>
|
||||
<p class="about-text">AiToEarn 内容营销助手 v<span id="extensionVersion"></span></p>
|
||||
<p class="about-sub">扩展只打开并填充平台编辑器,最终发布由你确认</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
|
||||
+55
-100
@@ -1,119 +1,74 @@
|
||||
// ===== AiToEarn Options Page Script =====
|
||||
const platformGrid = document.querySelector('#platformGrid');
|
||||
const saveBtn = document.querySelector('#saveBtn');
|
||||
const resetBtn = document.querySelector('#resetBtn');
|
||||
const saveStatus = document.querySelector('#saveStatus');
|
||||
const extensionVersion = document.querySelector('#extensionVersion');
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:3002';
|
||||
let availablePlatforms = [];
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
const apiUrlInput = #apiUrl;
|
||||
const apiKeyInput = #apiKey;
|
||||
const testConnectionBtn = #testConnection;
|
||||
const toggleKeyVisibility = #toggleKeyVisibility;
|
||||
const connectionStatus = #connectionStatus;
|
||||
const defaultAutoPolish = #defaultAutoPolish;
|
||||
const saveBtn = #saveBtn;
|
||||
const resetBtn = #resetBtn;
|
||||
const saveStatus = #saveStatus;
|
||||
|
||||
// ===== Load Settings =====
|
||||
async function loadSettings() {
|
||||
const data = await chrome.storage.sync.get({
|
||||
apiUrl: DEFAULT_API_URL,
|
||||
apiKey: '',
|
||||
defaultPlatforms: ['wechat'],
|
||||
autoPolish: false
|
||||
});
|
||||
|
||||
apiUrlInput.value = data.apiUrl || DEFAULT_API_URL;
|
||||
apiKeyInput.value = data.apiKey || '';
|
||||
defaultAutoPolish.checked = data.autoPolish || false;
|
||||
|
||||
// Set platform checkboxes
|
||||
const saved = data.defaultPlatforms || [];
|
||||
('input[name="defaultPlatform"]').forEach(cb => {
|
||||
cb.checked = saved.includes(cb.value);
|
||||
});
|
||||
function getSelectedPlatforms() {
|
||||
return Array.from(document.querySelectorAll('input[name="defaultPlatform"]:checked'))
|
||||
.map(input => input.value);
|
||||
}
|
||||
|
||||
// ===== Save Settings =====
|
||||
async function saveSettings() {
|
||||
const platforms = Array.from(('input[name="defaultPlatform"]:checked')).map(cb => cb.value);
|
||||
function renderPlatforms(selectedIds) {
|
||||
platformGrid.replaceChildren();
|
||||
const selected = new Set(selectedIds);
|
||||
for (const platform of availablePlatforms) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'platform-item';
|
||||
|
||||
const settings = {
|
||||
apiUrl: apiUrlInput.value.trim() || DEFAULT_API_URL,
|
||||
apiKey: apiKeyInput.value.trim(),
|
||||
defaultPlatforms: platforms,
|
||||
autoPolish: defaultAutoPolish.checked
|
||||
};
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = 'defaultPlatform';
|
||||
input.value = platform.id;
|
||||
input.checked = selected.has(platform.id);
|
||||
|
||||
await chrome.storage.sync.set(settings);
|
||||
showSaveStatus('✓ 设置已保存');
|
||||
}
|
||||
const text = document.createElement('span');
|
||||
text.className = 'platform-tag';
|
||||
text.textContent = platform.title || platform.name || platform.id;
|
||||
|
||||
// ===== Test Connection =====
|
||||
async function testConnection() {
|
||||
const url = apiUrlInput.value.trim() || DEFAULT_API_URL;
|
||||
testConnectionBtn.disabled = true;
|
||||
testConnectionBtn.textContent = '测试中...';
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = 'Bearer ' + apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url + '/health', {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showConnectionStatus('success', '✓ 连接成功!服务器运行正常');
|
||||
} else {
|
||||
showConnectionStatus('error', '✗ 服务器响应异常 (HTTP ' + response.status + ')');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') {
|
||||
showConnectionStatus('error', '✗ 连接超时,请检查服务器地址');
|
||||
} else {
|
||||
showConnectionStatus('error', '✗ 无法连接到服务器: ' + err.message);
|
||||
}
|
||||
} finally {
|
||||
testConnectionBtn.disabled = false;
|
||||
testConnectionBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> 测试连接';
|
||||
label.append(input, text);
|
||||
platformGrid.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UI Helpers =====
|
||||
function showConnectionStatus(type, message) {
|
||||
connectionStatus.className = 'connection-status ' + type;
|
||||
connectionStatus.textContent = message;
|
||||
connectionStatus.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showSaveStatus(message) {
|
||||
saveStatus.className = 'save-status success';
|
||||
saveStatus.textContent = message;
|
||||
saveStatus.classList.remove('hidden');
|
||||
setTimeout(() => saveStatus.classList.add('hidden'), 3000);
|
||||
saveStatus.className = 'save-status success';
|
||||
window.setTimeout(() => saveStatus.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
// ===== Toggle API Key Visibility =====
|
||||
toggleKeyVisibility.addEventListener('click', () => {
|
||||
const isPassword = apiKeyInput.type === 'password';
|
||||
apiKeyInput.type = isPassword ? 'text' : 'password';
|
||||
async function loadSettings() {
|
||||
const [response, preferences] = await Promise.all([
|
||||
chrome.runtime.sendMessage({ type: 'GET_PLATFORMS' }),
|
||||
chrome.storage.sync.get({ defaultPlatforms: ['wechat'] }),
|
||||
]);
|
||||
if (response?.error) throw new Error(response.error);
|
||||
availablePlatforms = Array.isArray(response?.platforms)
|
||||
? response.platforms.filter(platform => platform?.id && platform?.publishUrl)
|
||||
: [];
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,6 +231,10 @@ body {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -307,6 +311,16 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.platform-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
@@ -343,6 +357,9 @@ body {
|
||||
.checkbox-text {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
+33
-150
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -7,178 +7,61 @@
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Header -->
|
||||
<main class="app">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<img src="icons/icon48.svg" alt="AiToEarn" class="logo">
|
||||
<div>
|
||||
<h1 class="title">AiToEarn</h1>
|
||||
<p class="subtitle">内容营销助手</p>
|
||||
<div class="logo">
|
||||
<img src="icons/icon48.png" alt="" class="logo-icon">
|
||||
<div class="logo-text">
|
||||
<h1>AiToEarn</h1>
|
||||
<p class="tagline">内容营销助手</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="settingsBtn" class="icon-btn" title="设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
<button id="settingsBtn" class="btn-icon" type="button" title="默认平台设置" aria-label="默认平台设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.6.68 1.02 1.3 1.02H21a2 2 0 1 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Extract Button -->
|
||||
<section class="section">
|
||||
<button id="extractBtn" class="btn btn-extract">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<span>提取当前页面</span>
|
||||
<span id="extractStatus" class="btn-status"></span>
|
||||
<section class="extract-section">
|
||||
<button id="extractBtn" class="btn btn-secondary btn-extract" type="button">
|
||||
提取当前页面
|
||||
</button>
|
||||
<div id="extractStatus" class="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- Content Form -->
|
||||
<section class="section content-form" id="contentForm">
|
||||
<section class="editor-section">
|
||||
<div class="form-group">
|
||||
<label class="label" for="titleInput">标题</label>
|
||||
<input type="text" id="titleInput" class="input" placeholder="页面标题将自动填充...">
|
||||
<label for="titleInput">标题</label>
|
||||
<input type="text" id="titleInput" class="input" placeholder="输入标题或先提取当前页面">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="contentInput">正文内容</label>
|
||||
<textarea id="contentInput" class="textarea" rows="6" placeholder="页面内容将自动填充..."></textarea>
|
||||
<div class="char-count">
|
||||
<span id="charCount">0</span> 字
|
||||
</div>
|
||||
<label for="contentInput">正文内容</label>
|
||||
<textarea id="contentInput" class="textarea" rows="6" placeholder="输入正文或先提取当前页面"></textarea>
|
||||
<div class="char-count"><span id="charCount">0</span> 字</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">封面图片</label>
|
||||
<div id="imagePreview" class="image-preview hidden">
|
||||
<img id="previewImg" src="" alt="封面图片">
|
||||
<button id="removeImage" class="image-remove">×</button>
|
||||
</div>
|
||||
<div id="noImage" class="no-image">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<span>未检测到图片</span>
|
||||
<label>目标平台</label>
|
||||
<div id="platformGrid" class="platform-grid" aria-live="polite"></div>
|
||||
<div class="action-bar">
|
||||
<button id="selectAll" class="btn btn-secondary" type="button">全选</button>
|
||||
<button id="selectNone" class="btn btn-secondary" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Platform Selector -->
|
||||
<section class="section" id="platformSection">
|
||||
<label class="label">发布平台</label>
|
||||
<div class="platform-grid">
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="wechat" checked>
|
||||
<span class="platform-tag wechat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.986a.96.96 0 0 1 0 1.92.96.96 0 0 1 0-1.92zm5.812 0a.96.96 0 0 1 0 1.92.96.96 0 0 1 0-1.92z"/><path d="M23.928 14.57c0-3.39-3.256-6.15-7.273-6.15-4.057 0-7.273 2.76-7.273 6.15 0 3.39 3.216 6.15 7.273 6.15.848 0 1.658-.128 2.415-.347a.72.72 0 0 1 .594.082l1.58.923a.274.274 0 0 0 .14.047c.134 0 .243-.11.243-.245 0-.06-.024-.118-.04-.176l-.324-1.227a.493.493 0 0 1 .177-.549c1.526-1.117 2.488-2.78 2.488-4.658zm-9.495-.816a.798.798 0 0 1 0-1.596.798.798 0 0 1 0 1.596zm4.444 0a.798.798 0 0 1 0-1.596.798.798 0 0 1 0 1.596z"/></svg>
|
||||
微信公众号
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="douyin">
|
||||
<span class="platform-tag douyin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 0 0-.79-.05A6.34 6.34 0 0 0 3.15 15.2a6.34 6.34 0 0 0 10.86 4.43v-7.15a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-.81-.07l.01-.14z"/></svg>
|
||||
抖音
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="xiaohongshu">
|
||||
<span class="platform-tag xiaohongshu">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
小红书
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="bilibili">
|
||||
<span class="platform-tag bilibili">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.124.929.373.249.249.373.551.373.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373z"/></svg>
|
||||
B站
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="zhihu">
|
||||
<span class="platform-tag zhihu">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M5.721 0C2.251 0 0 2.25 0 5.719V18.28C0 21.751 2.252 24 5.721 24h12.56C21.751 24 24 21.75 24 18.281V5.72C24 2.249 21.75 0 18.281 0zm1.964 4.078h4.519c.154 0 .28.125.28.28v1.257a.28.28 0 0 1-.28.28H9.388a.28.28 0 0 1-.281-.28V4.358c0-.155.126-.28.281-.28zm-.56 5.374c0-.155.126-.281.281-.281h6.078a.28.28 0 0 1 .281.281v.558a.28.28 0 0 1-.281.282H7.407a.28.28 0 0 1-.282-.282v-.558zm.282 2.239h5.52a.28.28 0 0 1 .28.28v.559a.28.28 0 0 1-.28.281H7.688a.28.28 0 0 1-.281-.281v-.559c0-.154.126-.28.281-.28z"/></svg>
|
||||
知乎
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="juejin">
|
||||
<span class="platform-tag juejin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L1 12l11 10 11-10L12 2zm0 2.8L20.5 12 12 19.2 3.5 12 12 4.8z"/></svg>
|
||||
掘金
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="csdn">
|
||||
<span class="platform-tag csdn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>
|
||||
CSDN
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="cnblogs">
|
||||
<span class="platform-tag cnblogs">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
博客园
|
||||
</span>
|
||||
</label>
|
||||
<label class="platform-item">
|
||||
<input type="checkbox" name="platform" value="twitter">
|
||||
<span class="platform-tag twitter">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
Twitter
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select-actions">
|
||||
<button id="selectAll" class="link-btn">全选</button>
|
||||
<span class="divider">|</span>
|
||||
<button id="selectNone" class="link-btn">取消全选</button>
|
||||
<div class="action-bar">
|
||||
<button id="publishBtn" class="btn btn-primary btn-publish" type="button" disabled>
|
||||
打开并填充编辑器
|
||||
</button>
|
||||
</div>
|
||||
<div id="statusBar" class="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<!-- AI Polish Toggle -->
|
||||
<section class="section">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
<span>AI 智能润色</span>
|
||||
<span class="toggle-hint">自动优化标题与正文</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="aiPolish">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Publish Button -->
|
||||
<section class="section">
|
||||
<button id="publishBtn" class="btn btn-publish" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
<span>一键发布</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div id="statusBar" class="status-bar hidden">
|
||||
<span id="statusIcon"></span>
|
||||
<span id="statusText"></span>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loadingOverlay" class="loading-overlay hidden">
|
||||
<div class="spinner"></div>
|
||||
<span id="loadingText">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">v<span id="extensionVersion"></span> · 填充后由你确认发布</footer>
|
||||
</main>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
[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'
|
||||
}
|
||||
|
||||
$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'
|
||||
}
|
||||
|
||||
$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { InteractionValidationError, normalizeDouyinRequest } from '../interaction/douyin.js';
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeDouyinRequest('listDouyinComments', { workId: '7570305000069549352' }),
|
||||
{ workId: '7570305000069549352', cursor: '0', count: 20 },
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Interaction bridge validation passed.');
|
||||
Reference in New Issue
Block a user