Files
2026-08-29 22:39:58 -07:00

434 lines
15 KiB
JavaScript

/**
* Brightwheel Exporter - Content Script
* Injected on https://schools.mybrightwheel.com/*
*/
(function () {
console.log('[Brightwheel Exporter] Content script initialized.');
// Extract student ID from URL, DOM, or Storage
function detectCurrentStudentId() {
// 1. Check URL pathname & search params
const pathMatch = window.location.pathname.match(/\/(?:students|children|profiles)\/([a-zA-Z0-9-]+)/);
if (pathMatch && pathMatch[1]) {
chrome.storage.local.set({ lastActiveStudentId: pathMatch[1] });
return pathMatch[1];
}
const urlParams = new URLSearchParams(window.location.search);
const paramId = urlParams.get('student_id') || urlParams.get('studentId') || urlParams.get('child_id');
if (paramId) {
chrome.storage.local.set({ lastActiveStudentId: paramId });
return paramId;
}
// 2. Check DOM links or attributes
const studentLink = document.querySelector('a[href*="/students/"], a[href*="/children/"], [data-student-id]');
if (studentLink) {
const href = studentLink.getAttribute('href') || '';
const hrefMatch = href.match(/\/(?:students|children)\/([a-zA-Z0-9-]+)/);
if (hrefMatch && hrefMatch[1]) {
chrome.storage.local.set({ lastActiveStudentId: hrefMatch[1] });
return hrefMatch[1];
}
const dataId = studentLink.getAttribute('data-student-id');
if (dataId) {
chrome.storage.local.set({ lastActiveStudentId: dataId });
return dataId;
}
}
// 3. Check localStorage for student UUID
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const val = localStorage.getItem(key);
if (typeof val === 'string' && (key.includes('student') || key.includes('child') || key.includes('user') || key.includes('auth'))) {
const uuidMatch = val.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (uuidMatch && uuidMatch[0]) {
chrome.storage.local.set({ lastActiveStudentId: uuidMatch[0] });
return uuidMatch[0];
}
}
}
} catch (e) {
// Storage access blocked or restricted
}
return null;
}
detectCurrentStudentId();
// Watch for SPA URL changes
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
detectCurrentStudentId();
}
}).observe(document, { subtree: true, childList: true });
// Deep extract student profiles from API payloads
function extractStudentsFromData(data) {
if (!data) return [];
const students = [];
const seenIds = new Set();
function addStudent(s) {
if (!s) return;
const sId = s.id || s.object_id || s.student_id;
if (sId && !seenIds.has(sId)) {
seenIds.add(sId);
students.push({
id: sId,
first_name: s.first_name || s.name?.split(' ')?.[0] || 'Child',
last_name: s.last_name || s.name?.split(' ')?.slice(1).join(' ') || '',
picture_url: s.picture_url || s.avatar_url || s.photo_url || s.profile_photo?.image_url || s.profile_photo?.thumbnail_url || null,
school_name: s.school_name || s.school?.name || s.room_name || s.room?.name || 'Brightwheel Profile',
room: s.room || null
});
}
}
if (Array.isArray(data.students)) data.students.forEach(addStudent);
if (Array.isArray(data.children)) data.children.forEach(addStudent);
if (Array.isArray(data.profiles)) data.profiles.forEach(addStudent);
if (Array.isArray(data.guardians)) {
data.guardians.forEach(g => {
if (Array.isArray(g.students)) g.students.forEach(addStudent);
if (Array.isArray(g.children)) g.children.forEach(addStudent);
});
}
if (data.student) addStudent(data.student);
if (data.user?.students) data.user.students.forEach(addStudent);
if (Array.isArray(data.activities) || Array.isArray(data)) {
const items = Array.isArray(data.activities) ? data.activities : data;
for (const item of items) {
if (item.target && item.target.user_type === 'student') {
addStudent(item.target);
}
}
}
return students;
}
// Fetch full student & session discovery directly within authenticated tab
async function discoverSessionAndStudents() {
const results = {
authenticated: false,
user: null,
students: [],
activeStudentId: detectCurrentStudentId(),
liveActivitiesSample: []
};
try {
const meRes = await fetch('https://schools.mybrightwheel.com/api/v1/users/me', {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (meRes) {
results.authenticated = true;
results.user = meRes;
const found = extractStudentsFromData(meRes);
found.forEach(s => results.students.push(s));
}
const guardianRes = await fetch('https://schools.mybrightwheel.com/api/v1/guardians/me', {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (guardianRes) {
results.authenticated = true;
const found = extractStudentsFromData(guardianRes);
found.forEach(s => {
if (!results.students.some(existing => existing.id === s.id)) {
results.students.push(s);
}
});
}
const actProbe = await fetch('https://schools.mybrightwheel.com/api/v1/activities?page_size=10', {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (actProbe) {
results.authenticated = true;
const acts = actProbe.activities || actProbe.items || [];
results.liveActivitiesSample = acts.slice(0, 5);
const fromActs = extractStudentsFromData(acts);
fromActs.forEach(s => {
if (!results.students.some(existing => existing.id === s.id)) {
results.students.push(s);
}
});
}
if (results.students.length > 0 && !results.activeStudentId) {
results.activeStudentId = results.students[0].id;
chrome.storage.local.set({ lastActiveStudentId: results.activeStudentId });
}
} catch (e) {
console.warn('[Brightwheel Exporter] Discovery note:', e);
}
return results;
}
// Listen for messages from extension popup or dashboard
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'GET_PAGE_CONTEXT') {
const studentId = detectCurrentStudentId();
sendResponse({
url: window.location.href,
studentId: studentId,
cookiesPresent: document.cookie.includes('_brightwheel_v2')
});
return true;
}
if (message.action === 'GET_STUDENTS_AND_SESSION') {
discoverSessionAndStudents().then(res => {
sendResponse({ success: true, ...res });
}).catch(err => {
sendResponse({ success: false, error: err.message });
});
return true;
}
if (message.action === 'SCRAPE_DOM_FEED') {
const cards = scrapeDomFeedCards();
sendResponse({
success: true,
activities: cards,
count: cards.length
});
return true;
}
if (message.action === 'INSPECT_LIVE_FEED') {
(async () => {
try {
const studentId = message.studentId || detectCurrentStudentId();
const endpointProbes = {};
async function probe(name, url) {
try {
const res = await fetch(url, {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
const json = await res.json().catch(() => null);
const items = json?.activities || json?.items || (Array.isArray(json) ? json : []);
endpointProbes[name] = {
url,
status: res.status,
ok: res.ok,
itemCount: items.length,
latestDate: items[0]?.event_date || items[0]?.created_at || null,
earliestDate: items[items.length - 1]?.event_date || items[items.length - 1]?.created_at || null,
sampleItems: items.slice(0, 5),
rawSample: json
};
} catch (e) {
endpointProbes[name] = { url, error: e.message };
}
}
// 1. Probe general activities endpoint
await probe('parent_activities_feed', 'https://schools.mybrightwheel.com/api/v1/activities?page_size=25');
// 2. Probe student-specific endpoint
if (studentId) {
await probe('student_activities_feed', `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=25`);
}
// 3. Probe user/guardian profiles
await probe('users_me', 'https://schools.mybrightwheel.com/api/v1/users/me');
await probe('guardians_me', 'https://schools.mybrightwheel.com/api/v1/guardians/me');
// 4. Probe feed / timeline
await probe('feed_endpoint', 'https://schools.mybrightwheel.com/api/v1/feed?page_size=25');
const domCards = scrapeDomFeedCards();
const reactFiberActivities = extractActivitiesFromReactFiber();
sendResponse({
success: true,
studentId,
currentUrl: window.location.href,
pageTitle: document.title,
endpointProbes,
domCards: domCards.slice(0, 20),
reactFiberCount: reactFiberActivities.length,
reactFiberSample: reactFiberActivities.slice(0, 5)
});
} catch (err) {
sendResponse({ success: false, error: err.message });
}
})();
return true;
}
});
// Deep React Fiber state extractor for rendered activities
function extractActivitiesFromReactFiber() {
const rawItems = [];
const seenIds = new Set();
function inspectProps(props) {
if (!props || typeof props !== 'object') return;
const candidate = props.activity || props.item || props.feedItem || props.record || props.event;
if (candidate && typeof candidate === 'object') {
const id = candidate.object_id || candidate.id;
if (id && !seenIds.has(id)) {
seenIds.add(id);
rawItems.push(candidate);
}
}
if (Array.isArray(props.activities)) {
props.activities.forEach(a => {
const id = a?.object_id || a?.id;
if (id && !seenIds.has(id)) {
seenIds.add(id);
rawItems.push(a);
}
});
}
}
try {
document.querySelectorAll('*').forEach(node => {
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
if (fiberKey && node[fiberKey]) {
let fiber = node[fiberKey];
let depth = 0;
while (fiber && depth < 10) {
inspectProps(fiber.memoizedProps);
inspectProps(fiber.pendingProps);
fiber = fiber.return;
depth++;
}
}
});
} catch (e) {
// Ignore
}
return rawItems;
}
// DOM Scraper for Brightwheel Web Feed
function scrapeDomFeedCards() {
const results = [];
// Broad selector covering common Brightwheel feed cards
const cardSelectors = [
'[data-testid*="activity"]',
'[data-testid*="feed"]',
'.activity-item',
'.feed-item',
'.timeline-item',
'article',
'[class*="ActivityCard"]',
'[class*="feed-card"]',
'[class*="FeedItem"]',
'[class*="TimelineItem"]'
];
let cards = document.querySelectorAll(cardSelectors.join(','));
if (!cards || cards.length === 0) {
// Fallback: look for cards by common structure
cards = document.querySelectorAll('div[class*="card"], div[class*="Card"]');
}
cards.forEach((card, index) => {
const text = card.innerText || '';
if (!text.trim()) return;
const cardId = card.getAttribute('data-id') || card.getAttribute('data-activity-id') || card.id || `dom_${index}`;
const img = card.querySelector('img[src*="cdn"], img[src*="brightwheel"], img[src*="cloudfront"], img[src*="s3"]');
const video = card.querySelector('video, source, a[href*=".mp4"], a[href*="video"]');
const videoUrl = video?.src || video?.getAttribute('src') || video?.getAttribute('href') || null;
const imageUrl = img?.src || null;
results.push({
dom_id: cardId,
raw_text: text,
image_url: imageUrl,
video_url: videoUrl,
class_name: card.className,
html_snippet: card.outerHTML.substring(0, 800)
});
});
return results;
}
// Inject a discreet floating button on Brightwheel page
function injectExporterButton() {
if (document.getElementById('bw-exporter-floating-btn')) return;
const btn = document.createElement('button');
btn.id = 'bw-exporter-floating-btn';
btn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export Brightwheel Data
`;
Object.assign(btn.style, {
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: '999999',
display: 'inline-flex',
alignItems: 'center',
padding: '10px 18px',
background: 'linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%)',
color: '#FFFFFF',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: '14px',
fontWeight: '600',
border: 'none',
borderRadius: '30px',
boxShadow: '0 4px 16px rgba(255, 107, 53, 0.4), 0 2px 6px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
transition: 'all 0.25s ease',
});
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'translateY(-2px) scale(1.03)';
btn.style.boxShadow = '0 6px 20px rgba(255, 107, 53, 0.5), 0 3px 8px rgba(0, 0, 0, 0.15)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'translateY(0) scale(1)';
btn.style.boxShadow = '0 4px 16px rgba(255, 107, 53, 0.4), 0 2px 6px rgba(0, 0, 0, 0.1)';
});
btn.addEventListener('click', () => {
chrome.runtime.sendMessage({ action: 'OPEN_DASHBOARD' });
});
document.body.appendChild(btn);
}
// Inject when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectExporterButton);
} else {
injectExporterButton();
}
})();