216 lines
7.7 KiB
TypeScript
216 lines
7.7 KiB
TypeScript
/**
|
||
* InsightReply Content Script
|
||
* 负责解析 X (Twitter) 页面 DOM,提取推文内容并注入交互按钮
|
||
*/
|
||
|
||
console.log('InsightReply Content Script Loaded');
|
||
|
||
// 1. 定义推文数据结构
|
||
interface TweetData {
|
||
id: string;
|
||
author: string;
|
||
content: string;
|
||
stats: {
|
||
replies: string;
|
||
retweets: string;
|
||
likes: string;
|
||
};
|
||
}
|
||
|
||
let relevanceKeywords: string[] = [];
|
||
|
||
chrome.storage.local.get(['relevance_keywords'], (res) => {
|
||
if (res.relevance_keywords) {
|
||
relevanceKeywords = String(res.relevance_keywords).split(',').map((k: string) => k.trim().toLowerCase()).filter(Boolean);
|
||
}
|
||
});
|
||
|
||
chrome.storage.onChanged.addListener((changes, namespace) => {
|
||
if (namespace === 'local' && changes.relevance_keywords) {
|
||
const val = String(changes.relevance_keywords.newValue || '');
|
||
relevanceKeywords = val.split(',').map((k: string) => k.trim().toLowerCase()).filter(Boolean);
|
||
}
|
||
});
|
||
|
||
// 2. 提取推文内容的逻辑
|
||
const parseStat = (statString: string): number => {
|
||
if (!statString) return 0;
|
||
const match = statString.match(/([\d,\.]+)\s*([KkMmB])?/i);
|
||
if (!match) return 0;
|
||
const baseStr = match[1] || '0';
|
||
let num = parseFloat(baseStr.replace(/,/g, ''));
|
||
const multiplier = match[2] ? match[2].toUpperCase() : '';
|
||
if (multiplier === 'K') num *= 1000;
|
||
if (multiplier === 'M') num *= 1000000;
|
||
if (multiplier === 'B') num *= 1000000000;
|
||
return num;
|
||
};
|
||
|
||
const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
|
||
try {
|
||
const textElement = tweetElement.querySelector('[data-testid="tweetText"]');
|
||
const authorElement = tweetElement.querySelector('[data-testid="User-Name"]');
|
||
const linkElement = tweetElement.querySelector('time')?.parentElement as HTMLAnchorElement;
|
||
|
||
if (!textElement || !authorElement || !linkElement) {
|
||
console.debug('[InsightReply] Missing elements for tweet extraction:', {
|
||
hasText: !!textElement,
|
||
hasAuthor: !!authorElement,
|
||
hasLink: !!linkElement
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const getStat = (testid: string) => {
|
||
const el = tweetElement.querySelector(`[data-testid="${testid}"]`);
|
||
return el?.getAttribute('aria-label') || '0';
|
||
};
|
||
|
||
const tweetId = linkElement.href.split('/').pop() || '';
|
||
|
||
return {
|
||
id: tweetId,
|
||
author: authorElement.textContent || 'Unknown',
|
||
content: textElement.textContent || '',
|
||
stats: {
|
||
replies: getStat('reply'),
|
||
retweets: getStat('retweet'),
|
||
likes: getStat('like'),
|
||
}
|
||
};
|
||
} catch (e) {
|
||
console.error('Failed to extract tweet data:', e);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const injectBadges = (tweetElement: HTMLElement, data: TweetData) => {
|
||
if (tweetElement.dataset.insightBadged === 'true') return;
|
||
tweetElement.dataset.insightBadged = 'true';
|
||
|
||
const likes = parseStat(data.stats.likes);
|
||
const retweets = parseStat(data.stats.retweets);
|
||
const replies = parseStat(data.stats.replies);
|
||
const heatScore = likes * 1 + retweets * 2 + replies * 3;
|
||
|
||
let badgeText = '';
|
||
let badgeStyle = '';
|
||
|
||
if (heatScore > 50000) {
|
||
badgeText = '🔥 Trending';
|
||
badgeStyle = 'color: #f97316; background: rgba(249, 115, 22, 0.1); border: 1px solid rgba(249, 115, 22, 0.2);';
|
||
} else if (heatScore > 5000) {
|
||
badgeText = '⚡ Rising';
|
||
badgeStyle = 'color: #3b82f6; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);';
|
||
}
|
||
|
||
let isRelevant = false;
|
||
if (relevanceKeywords.length > 0 && data.content) {
|
||
const contentLower = data.content.toLowerCase();
|
||
isRelevant = relevanceKeywords.some(kw => contentLower.includes(kw));
|
||
}
|
||
|
||
if (!badgeText && !isRelevant) return;
|
||
|
||
const authorSection = tweetElement.querySelector('[data-testid="User-Name"]');
|
||
if (!authorSection) return;
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'insight-badges-container';
|
||
container.style.cssText = `display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;`;
|
||
|
||
if (badgeText) {
|
||
const heatBadge = document.createElement('span');
|
||
heatBadge.style.cssText = `padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; ${badgeStyle}`;
|
||
heatBadge.innerText = badgeText;
|
||
container.appendChild(heatBadge);
|
||
}
|
||
|
||
if (isRelevant) {
|
||
const relBadge = document.createElement('span');
|
||
relBadge.style.cssText = `padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; color: #10b981; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2);`;
|
||
relBadge.innerText = '🎯 Relevant';
|
||
container.appendChild(relBadge);
|
||
}
|
||
|
||
const firstLine = authorSection.firstElementChild;
|
||
if (firstLine) {
|
||
firstLine.appendChild(container);
|
||
} else {
|
||
authorSection.appendChild(container);
|
||
}
|
||
};
|
||
|
||
// 3. 注入“Insight”按钮
|
||
const injectInsightButton = (tweetElement: HTMLElement) => {
|
||
const actionBar = tweetElement.querySelector('[role="group"]');
|
||
if (!actionBar || actionBar.querySelector('.insight-reply-btn')) return;
|
||
|
||
const btnContainer = document.createElement('div');
|
||
btnContainer.className = 'insight-reply-btn';
|
||
btnContainer.style.display = 'flex';
|
||
btnContainer.style.alignItems = 'center';
|
||
btnContainer.style.marginLeft = '12px';
|
||
btnContainer.style.cursor = 'pointer';
|
||
|
||
const innerDiv = document.createElement('div');
|
||
innerDiv.style.cssText = 'padding: 4px; border-radius: 9999px; transition: background 0.2s;';
|
||
|
||
// Use event listeners instead of inline handlers to comply with Content Security Policy
|
||
innerDiv.addEventListener('mouseover', () => {
|
||
innerDiv.style.background = 'rgba(139, 92, 246, 0.1)';
|
||
});
|
||
innerDiv.addEventListener('mouseout', () => {
|
||
innerDiv.style.background = 'transparent';
|
||
});
|
||
|
||
innerDiv.innerHTML = `
|
||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" style="color: #8B5CF6;">
|
||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path>
|
||
</svg>
|
||
`;
|
||
btnContainer.appendChild(innerDiv);
|
||
|
||
btnContainer.onclick = (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
console.log('[InsightReply] Insight button clicked');
|
||
|
||
const data = extractTweetData(tweetElement);
|
||
if (data) {
|
||
console.log('[InsightReply] Extracted Tweet Data:', data);
|
||
chrome.runtime.sendMessage({ type: 'SHOW_INSIGHT', payload: data }, (response) => {
|
||
console.log('[InsightReply] Background script responsed SHOW_INSIGHT with:', response);
|
||
if (chrome.runtime.lastError) {
|
||
console.error('[InsightReply] Error sending SHOW_INSIGHT message:', chrome.runtime.lastError);
|
||
}
|
||
});
|
||
} else {
|
||
console.warn('[InsightReply] Failed to extract tweet data on click');
|
||
}
|
||
};
|
||
|
||
actionBar.appendChild(btnContainer);
|
||
};
|
||
|
||
// 4. 定时或监听 DOM 变化进行扫描
|
||
const scanTweets = () => {
|
||
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
|
||
tweets.forEach((tweet) => {
|
||
const el = tweet as HTMLElement;
|
||
injectInsightButton(el);
|
||
const data = extractTweetData(el);
|
||
if (data) injectBadges(el, data);
|
||
});
|
||
};
|
||
|
||
// 使用 MutationObserver 监听动态加载
|
||
const observer = new MutationObserver(() => {
|
||
scanTweets();
|
||
});
|
||
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
|
||
// 初始扫描
|
||
scanTweets();
|