Wave 2: Migrate scraper to StructuredLogger, add crash detection & topic tags

- Task #2: Migrate scraper_clean.py to use StructuredLogger with categories
  (37 log calls with metrics across browser/scraper/network/system)
- Task #4: Add crash_reports table schema and database methods
  (save_crash_report, get_crash_report, get_crash_stats)
- Task #9: Implement crash detection wrapper with metrics sampling
  (get_chrome_memory, get_dom_node_count, classify_crash)
- Task #17: Add topic tags to frontend ReviewAnalytics
  (topic filter UI, tags on cards, topics in modal)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 12:17:23 +00:00
parent 313e32f358
commit 9e1bcde981
4 changed files with 526 additions and 74 deletions

View File

@@ -20,6 +20,7 @@ interface ReviewWithNew extends Review {
is_new?: boolean;
owner_response?: OwnerResponse | null;
photo_urls?: string[] | null;
topics?: string[];
}
interface ReviewTopic {
@@ -47,6 +48,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
const [selectedReview, setSelectedReview] = useState<ReviewWithNew | null>(null);
const [showOnlyNew, setShowOnlyNew] = useState(false);
const [brushRange, setBrushRange] = useState<{ startIndex: number; endIndex: number } | null>(null);
const [selectedTopics, setSelectedTopics] = useState<string[]>([]);
// Check if we have comparison data
const hasComparisonData = reviews.some(r => r.is_new !== undefined);
@@ -67,6 +69,19 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
// Calculate timeline data for chart
const timelineData = useMemo(() => calculateTimelineData(dateFilteredReviews), [dateFilteredReviews]);
// Calculate available topics with counts
const availableTopics = useMemo(() => {
const topicCounts = new Map<string, number>();
reviews.forEach(r => {
r.topics?.forEach(t => {
topicCounts.set(t, (topicCounts.get(t) || 0) + 1);
});
});
return Array.from(topicCounts.entries())
.map(([topic, count]) => ({ topic, count }))
.sort((a, b) => b.count - a.count);
}, [reviews]);
// Check if brush covers the full range (no filtering needed)
const isFullRange = useMemo(() => {
if (!brushRange || timelineData.length === 0) return true;
@@ -155,7 +170,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
return 'all';
}, [brushRange, timelineData]);
// Filter reviews by selected ratings, sentiments, response status, new status, and brush range (for table)
// Filter reviews by selected ratings, sentiments, response status, new status, topics, and brush range (for table)
const filteredReviews = useMemo(() => {
return dateFilteredReviews.filter(r => {
const matchesRating = selectedRatings.includes(r.rating);
@@ -174,6 +189,10 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
(hasResponse && selectedResponseStatus.includes('answered')) ||
(!hasResponse && selectedResponseStatus.includes('not_answered'));
// Filter by selected topics
const matchesTopics = selectedTopics.length === 0 ||
r.topics?.some(t => selectedTopics.includes(t));
// Filter by brush date range if active
let matchesBrush = true;
if (brushDateRange && r.centerDate) {
@@ -189,9 +208,9 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
matchesBrush = r.centerDate >= startDate && r.centerDate < endDate;
}
return matchesRating && matchesSentiment && matchesSearch && matchesNew && matchesResponseStatus && matchesBrush;
return matchesRating && matchesSentiment && matchesSearch && matchesNew && matchesResponseStatus && matchesTopics && matchesBrush;
});
}, [dateFilteredReviews, selectedRatings, selectedSentiments, selectedResponseStatus, globalFilter, showOnlyNew, brushDateRange]);
}, [dateFilteredReviews, selectedRatings, selectedSentiments, selectedResponseStatus, globalFilter, showOnlyNew, selectedTopics, brushDateRange]);
const toggleRating = (rating: number) => {
setSelectedRatings(prev =>
@@ -211,6 +230,14 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
);
};
const toggleTopicFilter = (topic: string) => {
setSelectedTopics(prev =>
prev.includes(topic)
? prev.filter(t => t !== topic)
: [...prev, topic]
);
};
const clearAllFilters = () => {
setDateRange('all');
setSelectedRatings([1, 2, 3, 4, 5]);
@@ -219,6 +246,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
setGlobalFilter('');
setShowOnlyNew(false);
setBrushRange(null);
setSelectedTopics([]);
};
const hasActiveFilters = dateRange !== 'all' ||
@@ -227,7 +255,8 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
selectedSentiments.length < 3 ||
selectedResponseStatus.length < 2 ||
globalFilter !== '' ||
showOnlyNew;
showOnlyNew ||
selectedTopics.length > 0;
const exportFilteredData = () => {
const dataStr = JSON.stringify(filteredReviews, null, 2);
@@ -364,6 +393,8 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
const hasResponse = !!ownerResponse?.text;
const photoUrls = row.original.photo_urls;
const hasPhotos = photoUrls && photoUrls.length > 0;
const topics = row.original.topics;
const hasTopics = topics && topics.length > 0;
return (
<div className="max-w-2xl">
@@ -387,6 +418,19 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
<p className={`text-gray-800 ${!expanded && 'line-clamp-2'}`}>
{text}
</p>
{hasTopics && (
<div className="flex flex-wrap gap-1 mt-2">
{topics.map(topic => (
<span
key={topic}
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded-full cursor-pointer hover:bg-indigo-200 transition-colors"
onClick={(e) => { e.stopPropagation(); toggleTopicFilter(topic); }}
>
{topic}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 mt-1">
{text.length > 100 && (
<button
@@ -458,7 +502,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
},
},
],
[]
[toggleTopicFilter]
);
const table = useReactTable({
@@ -603,6 +647,35 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
</div>
)}
{/* Topic Filter */}
{availableTopics.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<MessageSquare className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Topics:</span>
{availableTopics.slice(0, 10).map(({topic, count}) => (
<button
key={topic}
onClick={() => toggleTopicFilter(topic)}
className={`px-3 py-1 rounded-full text-sm font-semibold transition-all border ${
selectedTopics.includes(topic)
? 'bg-indigo-600 text-white border-indigo-700'
: 'bg-indigo-100 text-indigo-700 border-indigo-200 hover:bg-indigo-200'
}`}
>
{topic} ({count})
</button>
))}
{selectedTopics.length > 0 && (
<button
onClick={() => setSelectedTopics([])}
className="px-2 py-1 text-xs text-gray-500 hover:text-gray-700"
>
Clear topics
</button>
)}
</div>
)}
{/* Filter Summary */}
<div className="flex items-center justify-between pt-2 border-t-2 border-gray-200">
<span className="text-sm font-medium text-gray-600">
@@ -1535,6 +1608,37 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
</div>
</div>
{/* Review Topics */}
{selectedReview.topics && selectedReview.topics.length > 0 && (
<div className="bg-indigo-50 rounded-xl p-4 border-2 border-indigo-200">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="w-5 h-5 text-indigo-700" />
<span className="font-semibold text-indigo-900">Topics</span>
<span className="px-2 py-0.5 bg-indigo-200 text-indigo-800 text-xs font-bold rounded-full">
{selectedReview.topics.length} topic{selectedReview.topics.length > 1 ? 's' : ''}
</span>
</div>
<div className="flex flex-wrap gap-2">
{selectedReview.topics.map(topic => (
<button
key={topic}
onClick={() => {
toggleTopicFilter(topic);
setSelectedReview(null);
}}
className={`px-3 py-1 rounded-full text-sm font-semibold transition-all border ${
selectedTopics.includes(topic)
? 'bg-indigo-600 text-white border-indigo-700'
: 'bg-white text-indigo-700 border-indigo-300 hover:bg-indigo-100'
}`}
>
{topic}
</button>
))}
</div>
</div>
)}
{/* Owner Response */}
{selectedReview.owner_response?.text ? (
<div className="bg-emerald-50 rounded-xl p-4 border-2 border-emerald-200">