Compare commits

...

2 Commits

16 changed files with 787 additions and 63 deletions
+6
View File
@@ -3,7 +3,9 @@
AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打包发布的扩展源码,用于:
- 从当前网页采集标题、正文和图片。
- 在普通 HTTP 采集不足时复用当前 Chrome 标签页,等待动态页面渲染后回传正文、HTML、图片和链接。
- 在 AiToEarn 页面提供 `window.AIToEarnDistribution` 分发桥接。
- 在 AiToEarn 页面提供独立的 `window.AIToEarnInteraction` 互动读取桥接,不冒充旧官方扩展的完整能力。
- 复用浏览器现有登录状态检测内容平台账号。
- 将同一份 Markdown、HTML 或纯文本内容填入平台编辑器。
- 扩展弹窗从 COSE 适配器动态读取平台列表,并可将当前网页内容打开、填充到所选平台编辑器。
@@ -12,6 +14,10 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
扩展弹窗和后台通知中的“成功”只表示平台编辑器已打开并完成内容填充,不表示内容已经公开发布。右键菜单“提取此页面到 AiToEarn”会把最近一次提取结果暂存到浏览器本地,随后打开扩展即可继续选择平台。
AiToEarn 后台触发浏览器采集时会优先复用同地址标签页;没有现成标签页时才在当前 Chrome 内创建后台标签页。普通采集完成后临时标签页会关闭,遇到登录、验证码或地区访问限制时则保留并激活页面,等待用户处理。
当前互动桥接只开放受控白名单:能力查询、抖音评论列表和抖音回复列表。作品 ID、评论 ID、游标和数量均由扩展校验,网页不能向扩展传入任意请求地址。扩展优先复用当前 Chrome 中已有的抖音标签页,没有可复用标签页时才创建非激活标签页,并在抖音页面上下文内发起带现有 Cookie 的请求。
## 上游来源
多平台检测与编辑器填充逻辑基于 `doocs/cose`,固定版本和许可证见:
+123
View File
@@ -1,6 +1,113 @@
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' });
@@ -86,6 +193,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 监听编辑器出现
+49
View File
@@ -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);
}
});
})();
+65
View File
@@ -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'));
})();
+147
View File
@@ -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;
});
+213
View File
@@ -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);
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.1.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测多平台文章分发",
"version": "1.3.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测多平台文章分发与受控互动读取",
"permissions": [
"activeTab",
"clipboardRead",
@@ -83,7 +83,7 @@
"http://127.0.0.1:6061/*",
"https://wx.frp.it1024.cc/*"
],
"js": ["distribution-page-bridge.js"],
"js": ["distribution-page-bridge.js", "interaction-page-bridge.js"],
"run_at": "document_start",
"world": "MAIN"
},
@@ -93,7 +93,7 @@
"http://127.0.0.1:6061/*",
"https://wx.frp.it1024.cc/*"
],
"js": ["distribution-bridge.js"],
"js": ["distribution-bridge.js", "interaction-bridge.js"],
"run_at": "document_start"
},
{
+9 -1
View File
@@ -17,6 +17,8 @@ $requiredPaths = @(
'content.js',
'distribution-bridge.js',
'distribution-page-bridge.js',
'interaction-bridge.js',
'interaction-page-bridge.js',
'popup.html',
'popup.css',
'popup.js',
@@ -24,7 +26,8 @@ $requiredPaths = @(
'options.css',
'options.js',
'icons',
'distribution'
'distribution',
'interaction'
)
foreach ($relativePath in $requiredPaths) {
@@ -47,6 +50,11 @@ foreach ($javascriptFile in $javascriptFiles) {
}
}
& 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"
+35
View File
@@ -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.');