chore: import broken Phase-1 extension as received
Snapshot of /home/parf/Downloads/manifest.zip as delivered, before any
GSD-driven remediation. Contains a partially-broken first attempt at the
Russian SPEC "Тз расширение фаза1.md" (Phase 1 of operator-session-recorder).
Source layout:
- manifest.json — MV3 declaration with tabCapture/activeTab/downloads/etc.
- src/background/index.ts — service worker (video buffer + archive packaging)
- src/content/index.ts — rrweb + user-event logger
- src/popup/{index.html,index.ts,style.css} — Russian popup UI
- offscreen/{index.html,index.ts} — orphaned offscreen (see audit)
- vite.config.ts — inline plugin emitting a separate live offscreen.js
- generate-icons.js, icons/ — minimal PNG icons
- "Тз расширение фаза1.md" — authoritative Russian SPEC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
358
src/content/index.ts
Normal file
358
src/content/index.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { record } from 'rrweb';
|
||||
import { ContentLogger } from '../shared/logger';
|
||||
import type { UserEvent } from '../shared/types';
|
||||
|
||||
interface RrwebEventsResponse {
|
||||
events: EventWithTime[];
|
||||
userEvents: UserEvent[];
|
||||
}
|
||||
|
||||
type EventWithTime = any;
|
||||
|
||||
const logger = new ContentLogger('Main');
|
||||
|
||||
// Константы
|
||||
const RRWEB_MAX_EVENTS = 5000;
|
||||
const RRWEB_RETENTION_MS = 10 * 60 * 1000; // 10 минут
|
||||
const USER_EVENT_RETENTION_MS = 10 * 60 * 1000; // 10 минут
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000; // 1 минута
|
||||
|
||||
// Буфер rrweb событий
|
||||
let rrwebEvents: EventWithTime[] = [];
|
||||
|
||||
// Буфер пользовательских событий
|
||||
let userEvents: UserEvent[] = [];
|
||||
|
||||
// Очистка старых событий
|
||||
function cleanupOldEvents() {
|
||||
const now = Date.now();
|
||||
|
||||
// Очистка rrweb событий
|
||||
const rrwebBeforeCount = rrwebEvents.length;
|
||||
rrwebEvents = rrwebEvents.filter(event => {
|
||||
return (now - event.timestamp) < RRWEB_RETENTION_MS;
|
||||
});
|
||||
logger.log(`rrweb cleanup: ${rrwebBeforeCount} -> ${rrwebEvents.length} events`);
|
||||
|
||||
// Если все равно слишком много событий, удаляем самые старые
|
||||
if (rrwebEvents.length > RRWEB_MAX_EVENTS) {
|
||||
const toRemove = rrwebEvents.length - RRWEB_MAX_EVENTS;
|
||||
rrwebEvents = rrwebEvents.slice(toRemove);
|
||||
logger.log(`rrweb limit exceeded, removed ${toRemove} oldest events`);
|
||||
}
|
||||
|
||||
// Очистка пользовательских событий
|
||||
const userBeforeCount = userEvents.length;
|
||||
userEvents = userEvents.filter(event => {
|
||||
return (now - event.timestamp) < USER_EVENT_RETENTION_MS;
|
||||
});
|
||||
logger.log(`user events cleanup: ${userBeforeCount} -> ${userEvents.length} events`);
|
||||
}
|
||||
|
||||
// Добавление пользовательского события
|
||||
function addUserEvent(event: UserEvent) {
|
||||
event.timestamp = Date.now();
|
||||
event.url = window.location.href;
|
||||
userEvents.push(event);
|
||||
logger.log(`User event: ${event.type}`, event);
|
||||
}
|
||||
|
||||
// Логирование кликов
|
||||
function setupClickLogging() {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const selector = getSelector(target);
|
||||
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'click',
|
||||
target: selector,
|
||||
value: target.textContent?.substring(0, 100) || '',
|
||||
url: window.location.href
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Логирование ввода (без паролей)
|
||||
function setupInputLogging() {
|
||||
document.addEventListener('input', (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
// Пропускаем пароли
|
||||
if (target.type === 'password') {
|
||||
return;
|
||||
}
|
||||
|
||||
const selector = getSelector(target);
|
||||
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'input',
|
||||
target: selector,
|
||||
value: target.value.substring(0, 200), // Ограничиваем длину
|
||||
url: window.location.href
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Логирование навигации
|
||||
function setupNavigationLogging() {
|
||||
const handleNavigation = () => {
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'navigation',
|
||||
target: 'window',
|
||||
value: window.location.href,
|
||||
url: window.location.href,
|
||||
meta: { previousUrl: history.state?.url || 'unknown' }
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handleNavigation);
|
||||
window.addEventListener('hashchange', handleNavigation);
|
||||
|
||||
// Интерсепт History API
|
||||
// @ts-ignore
|
||||
const originalPushState = history.pushState;
|
||||
// @ts-ignore
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
// @ts-ignore
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(this, args);
|
||||
handleNavigation();
|
||||
};
|
||||
// @ts-ignore
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
handleNavigation();
|
||||
};
|
||||
}
|
||||
|
||||
// Логирование JS ошибок
|
||||
function setupErrorLogging() {
|
||||
window.addEventListener('error', (event) => {
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'js_error',
|
||||
target: event.filename || 'unknown',
|
||||
value: event.message,
|
||||
url: window.location.href,
|
||||
meta: {
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
stack: event.error?.stack
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'js_error',
|
||||
target: 'unhandledrejection',
|
||||
value: event.reason?.toString() || 'Unknown rejection',
|
||||
url: window.location.href,
|
||||
meta: {
|
||||
promise: event.promise.toString()
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Логирование сетевых ошибок
|
||||
function setupNetworkLogging() {
|
||||
// Перехват fetch
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
return originalFetch.apply(this, args)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'network_error',
|
||||
target: args[0]?.toString() || 'unknown',
|
||||
value: `HTTP ${response.status} ${response.statusText}`,
|
||||
url: window.location.href,
|
||||
meta: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: response.url
|
||||
}
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'network_error',
|
||||
target: args[0]?.toString() || 'unknown',
|
||||
value: error.message,
|
||||
url: window.location.href,
|
||||
meta: {
|
||||
errorType: error.name
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// Перехват XMLHttpRequest
|
||||
// @ts-ignore
|
||||
const originalXHROpen = XMLHttpRequest.prototype.open;
|
||||
// @ts-ignore
|
||||
const originalXHRSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
// @ts-ignore
|
||||
XMLHttpRequest.prototype.open = function(...args) {
|
||||
(this as any)._method = args[0];
|
||||
(this as any)._url = args[1];
|
||||
// @ts-ignore
|
||||
return originalXHROpen.apply(this, args);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
this.addEventListener('loadend', function() {
|
||||
const xhr = this as XMLHttpRequest;
|
||||
if (xhr.status >= 400 || xhr.status === 0) {
|
||||
addUserEvent({
|
||||
timestamp: Date.now(),
|
||||
type: 'network_error',
|
||||
target: (xhr as any)._url || 'unknown',
|
||||
value: `HTTP ${xhr.status} ${xhr.statusText || 'Network Error'}`,
|
||||
url: window.location.href,
|
||||
meta: {
|
||||
method: (xhr as any)._method,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// @ts-ignore
|
||||
return originalXHRSend.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
// Получение CSS селектора элемента
|
||||
function getSelector(element: HTMLElement): string {
|
||||
if (element.id) {
|
||||
return `#${element.id}`;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
||||
if (current.id) {
|
||||
parts.unshift(`#${current.id}`);
|
||||
break;
|
||||
} else {
|
||||
let tagName = current.tagName.toLowerCase();
|
||||
if (current === element) {
|
||||
// Для целевого элемента берем точный путь
|
||||
parts.unshift(tagName);
|
||||
} else {
|
||||
let siblingIndex = 0;
|
||||
let sibling = current.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.nodeType === Node.ELEMENT_NODE &&
|
||||
(sibling as HTMLElement).tagName === current.tagName) {
|
||||
siblingIndex++;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
if (siblingIndex > 0) {
|
||||
parts.unshift(`${tagName}:nth-of-type(${siblingIndex + 1})`);
|
||||
} else {
|
||||
parts.unshift(tagName);
|
||||
}
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
if (parts.length >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
// Инициализация rrweb
|
||||
function initRrweb() {
|
||||
record({
|
||||
emit(event) {
|
||||
rrwebEvents.push(event);
|
||||
},
|
||||
maskInputOptions: {
|
||||
color: false,
|
||||
date: false,
|
||||
'datetime-local': false,
|
||||
email: false,
|
||||
month: false,
|
||||
number: false,
|
||||
range: false,
|
||||
search: false,
|
||||
tel: false,
|
||||
text: false,
|
||||
time: false,
|
||||
url: false,
|
||||
week: false,
|
||||
textarea: false,
|
||||
select: false,
|
||||
password: true, // Маскируем только пароли
|
||||
},
|
||||
maskTextSelector: '[data-sensitive="true"]',
|
||||
});
|
||||
|
||||
logger.log('rrweb recorder initialized');
|
||||
}
|
||||
|
||||
// Обработка сообщений от Service Worker
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
logger.log('Received message:', message);
|
||||
|
||||
if (message.type === 'GET_RRWEB_EVENTS') {
|
||||
const response: RrwebEventsResponse = {
|
||||
events: rrwebEvents,
|
||||
userEvents: userEvents
|
||||
};
|
||||
logger.log(`Sending ${rrwebEvents.length} rrweb events and ${userEvents.length} user events`);
|
||||
sendResponse(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Запуск
|
||||
logger.log('Content script starting');
|
||||
|
||||
// Ждем готовности DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
|
||||
function start() {
|
||||
logger.log('Starting content script on:', window.location.href);
|
||||
|
||||
// Инициализация rrweb
|
||||
initRrweb();
|
||||
|
||||
// Настройка логирования
|
||||
setupClickLogging();
|
||||
setupInputLogging();
|
||||
setupNavigationLogging();
|
||||
setupErrorLogging();
|
||||
setupNetworkLogging();
|
||||
|
||||
// Запуск очистки старых событий
|
||||
setInterval(cleanupOldEvents, CLEANUP_INTERVAL_MS);
|
||||
|
||||
logger.log('Content script initialized');
|
||||
}
|
||||
Reference in New Issue
Block a user