Compare commits

...

1 Commits

Author SHA1 Message Date
admin ef620109cf feat(extension): add rendered page collection fallback 2026-08-01 19:14:30 +08:00
10 changed files with 262 additions and 59 deletions
+3
View File
@@ -3,6 +3,7 @@
AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打包发布的扩展源码,用于:
- 从当前网页采集标题、正文和图片。
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
- 复用浏览器现有登录状态检测内容平台账号。
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
@@ -12,6 +13,8 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
扩展弹窗和后台通知中的“成功”只表示平台编辑器已打开并完成内容填充,不表示内容已经公开发布。右键菜单“提取此页面到 AiToEarn”会把最近一次提取结果暂存到浏览器本地,随后打开扩展即可继续选择平台。
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
## 上游来源
多平台检测与编辑器填充逻辑基于 `doocs/cose`,固定版本和许可证见:
+122
View File
@@ -1,6 +1,112 @@
import './distribution/cose/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' });
@@ -86,6 +192,22 @@ chrome.contextMenus.onClicked.addListener((info, tab) => {
});
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)
+112 -41
View File
@@ -3,17 +3,47 @@
(() => {
'use strict';
if (globalThis.__AI_TO_EARN_CONTENT_SCRIPT__) return;
globalThis.__AI_TO_EARN_CONTENT_SCRIPT__ = true;
const contentSelectors = [
'article',
'[role="main"]',
'main',
'.amos-land-page',
'.post-content',
'.article-content',
'.entry-content',
'.content-body',
'.markdown-body',
'.rich_media_content',
'#article-content',
'#content',
'.content',
'.post',
'.article',
'.story-body',
'#cnblogs_post_body',
'.htmledit_views',
'#article_content',
'.blog-content-box'
];
// ===== Content Extraction =====
function extractPageContent() {
const data = {
title: '',
description: '',
body: '',
html: '',
images: [],
links: [],
url: window.location.href,
siteName: '',
author: '',
publishDate: ''
publishDate: '',
pageType: 'article',
accessState: { status: 'ok', message: '', requiresUserAction: false }
};
// --- Title ---
@@ -43,10 +73,15 @@
|| '';
// --- 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;
}
@@ -58,34 +93,12 @@
// ===== 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);
+6
View File
@@ -5,6 +5,7 @@
'getVersion',
'listPlatforms',
'checkPlatforms',
'captureUrl',
'startBatch',
'syncToPlatform',
]);
@@ -33,6 +34,11 @@
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': {
+1
View File
@@ -44,6 +44,7 @@
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 }),
},
+13 -13
View File
@@ -868,7 +868,7 @@ async function syncToPlatform(platformId, content) {
await new Promise(resolve => setTimeout(resolve, 1000))
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.plainText || content.markdown || content.body || ''
console.log('[COSE] 小红书 HTML 内容长度:', htmlContent?.length || 0)
// 填充标题和内容
@@ -1583,7 +1583,7 @@ async function syncToPlatform(platformId, content) {
// 微信公众号:直接注入 HTML 到编辑器
if (platformId === 'wechat') {
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.wechatHtml || content.html || content.body || ''
console.log('[COSE] 微信 HTML 内容长度:', htmlContent?.length || 0)
// 等待额外时间确保编辑器完全加载
@@ -1753,7 +1753,7 @@ async function syncToPlatform(platformId, content) {
// 抖音:使用剪贴板 HTML 粘贴到编辑器(类似微信公众号)
if (platformId === 'douyin') {
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.plainText || content.markdown || content.body || ''
console.log('[COSE] 抖音 HTML 内容长度:', htmlContent?.length || 0)
console.log('[COSE] 开始注入抖音内容...')
@@ -1883,7 +1883,7 @@ async function syncToPlatform(platformId, content) {
await new Promise(resolve => setTimeout(resolve, 3000))
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 搜狐号 HTML 内容长度:', htmlContent?.length || 0)
// 填充标题和内容
@@ -1942,7 +1942,7 @@ async function syncToPlatform(platformId, content) {
// B站专栏:使用 UEditor execCommand 插入 HTML
if (platformId === 'bilibili') {
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.html || content.body || ''
console.log('[COSE] B站专栏 HTML 内容长度:', htmlContent?.length || 0)
// 等待 UEditor 就绪
@@ -2052,7 +2052,7 @@ async function syncToPlatform(platformId, content) {
await new Promise(resolve => setTimeout(resolve, 3000))
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.plainText || content.markdown || content.body || ''
console.log('[COSE] 微博头条 HTML 内容长度:', htmlContent?.length || 0)
// 填充标题和内容
@@ -2717,7 +2717,7 @@ async function syncToPlatform(platformId, content) {
await new Promise(resolve => setTimeout(resolve, 3000))
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 百家号 HTML 内容长度:', htmlContent?.length || 0)
// 填充标题和内容
@@ -2826,7 +2826,7 @@ async function syncToPlatform(platformId, content) {
await new Promise(resolve => setTimeout(resolve, 3000))
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 少数派 HTML 内容长度:', htmlContent?.length || 0)
// 填充标题和内容
@@ -2896,7 +2896,7 @@ async function syncToPlatform(platformId, content) {
await new Promise(resolve => setTimeout(resolve, 3000))
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 支付宝开放平台 HTML 内容长度:', htmlContent?.length || 0)
// 填充标题和内容
@@ -3196,7 +3196,7 @@ async function syncToPlatform(platformId, content) {
// 豆瓣:向首页分享框注入内容
if (platformId === 'douban') {
// 使用纯文本内容(豆瓣分享框不支持富文本)
const textContent = content.markdown || content.body || ''
const textContent = content.plainText || content.markdown || content.body || ''
console.log('[COSE] 豆瓣文本内容长度:', textContent?.length || 0)
// 等待页面加载
@@ -3389,7 +3389,7 @@ async function syncToPlatform(platformId, content) {
// 在目标页面执行的填充函数
function fillContentOnPage(content, platformId) {
const { title, body, markdown, wechatHtml } = content
const { title, body, html, markdown, plainText } = content
// 等待元素出现的工具函数
function waitFor(selector, timeout = 10000) {
@@ -3422,7 +3422,7 @@ function fillContentOnPage(content, platformId) {
// 根据平台填充内容
async function fill() {
const host = window.location.hostname
const contentToFill = markdown || body || ''
const contentToFill = markdown || plainText || body || ''
// 知乎专栏 - 由 syncToPlatform 单独处理(使用导入文档功能)
if (host.includes('zhihu.com')) {
@@ -3839,7 +3839,7 @@ function fillContentOnPage(content, platformId) {
* Medium 使用 contenteditable 编辑器,通过 paste 事件注入 HTML 内容
* 使用剪贴板 HTML(带完整样式)或降级到 body
*/
const htmlContent = wechatHtml || body || ''
const htmlContent = html || body || ''
const contentEl = document.querySelector('p.graf--p')
if (contentEl && htmlContent) {
+2 -2
View File
@@ -19,8 +19,8 @@ const MediumPlatform = {
* 3. 通过 paste 事件填充 HTML 内容到编辑器
*/
async function fillMediumContent(content, waitFor, setInputValue) {
const { title, body, wechatHtml } = content
const htmlContent = wechatHtml || body || ''
const { title, body, html } = content
const htmlContent = html || body || ''
console.log('[COSE] Medium 开始同步...')
@@ -94,7 +94,7 @@ async function syncWangyihaoContent(tab, content, helpers) {
await injectUtils(chrome, tab.id)
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.html || content.body || ''
console.log('[COSE] 网易号 HTML 内容长度:', htmlContent?.length || 0)
// 在页面中执行:填充标题和粘贴 HTML 内容
+1 -1
View File
@@ -331,7 +331,7 @@ async function syncWechatContent(tab, content, helpers) {
await waitForTab(tab.id)
// 使用剪贴板 HTML(带完整样式)或降级到 body
const htmlContent = content.wechatHtml || content.body
const htmlContent = content.wechatHtml || content.html || content.body || ''
console.log('[COSE] 微信 HTML 内容长度:', htmlContent?.length || 0)
// 步骤4:使用 MutationObserver 监听编辑器出现
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.1.0",
"version": "1.2.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测与多平台文章分发",
"permissions": [
"activeTab",