440 lines
13 KiB
JavaScript
440 lines
13 KiB
JavaScript
/**
|
||
* Automated Google Alert Setup Script
|
||
*
|
||
* This script:
|
||
* 1. Logs into Google (with manual intervention for first-time auth)
|
||
* 2. Reads alerts from markdown files
|
||
* 3. Creates each alert one at a time
|
||
* 4. Collects RSS feed URLs
|
||
* 5. Saves RSS feeds to a JSON file
|
||
*
|
||
* Usage:
|
||
* node scripts/setup-alerts-automated.js docs/google-alerts-reddit-tuned.md
|
||
*
|
||
* For first-time use, you'll need to manually log in once.
|
||
* The authentication state will be saved for future runs.
|
||
*/
|
||
|
||
import { chromium } from 'playwright';
|
||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||
import { existsSync } from 'fs';
|
||
import { join } from 'path';
|
||
|
||
const AUTH_STATE_PATH = join(process.cwd(), '.auth', 'google-auth.json');
|
||
const RSS_FEEDS_PATH = join(process.cwd(), 'rss-feeds.json');
|
||
|
||
/**
|
||
* Parse alerts from markdown file
|
||
*/
|
||
async function parseAlertsFromMarkdown(filePath) {
|
||
const content = await readFile(filePath, 'utf-8');
|
||
const lines = content.split('\n');
|
||
|
||
const alerts = [];
|
||
let currentAlert = null;
|
||
let inCodeBlock = false;
|
||
let queryLines = [];
|
||
let currentHeading = '';
|
||
|
||
for (const line of lines) {
|
||
// Track headings
|
||
if (line.startsWith('### ')) {
|
||
currentHeading = line.replace(/^### /, '').trim();
|
||
}
|
||
|
||
// Detect alert name
|
||
if (line.includes('**Alert Name:**')) {
|
||
if (currentAlert && queryLines.length > 0) {
|
||
currentAlert.query = queryLines.join('\n').trim();
|
||
if (currentAlert.query) {
|
||
alerts.push(currentAlert);
|
||
}
|
||
}
|
||
|
||
const match = line.match(/\*\*Alert Name:\*\*\s*`([^`]+)`/);
|
||
const name = match ? match[1] : line.split('**Alert Name:**')[1].trim();
|
||
|
||
currentAlert = {
|
||
name,
|
||
query: '',
|
||
heading: currentHeading
|
||
};
|
||
queryLines = [];
|
||
continue;
|
||
}
|
||
|
||
// Detect code blocks containing queries
|
||
if (line.trim() === '```') {
|
||
if (!inCodeBlock && currentAlert) {
|
||
inCodeBlock = true;
|
||
queryLines = [];
|
||
} else if (inCodeBlock) {
|
||
inCodeBlock = false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Collect query lines
|
||
if (inCodeBlock && currentAlert) {
|
||
queryLines.push(line);
|
||
}
|
||
}
|
||
|
||
// Add last alert
|
||
if (currentAlert && queryLines.length > 0) {
|
||
currentAlert.query = queryLines.join('\n').trim();
|
||
if (currentAlert.query) {
|
||
alerts.push(currentAlert);
|
||
}
|
||
}
|
||
|
||
return alerts.filter(alert => alert.query);
|
||
}
|
||
|
||
/**
|
||
* Load saved authentication state
|
||
*/
|
||
async function loadAuthState() {
|
||
if (existsSync(AUTH_STATE_PATH)) {
|
||
const authData = await readFile(AUTH_STATE_PATH, 'utf-8');
|
||
return JSON.parse(authData);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Save authentication state
|
||
*/
|
||
async function saveAuthState(context) {
|
||
const authDir = join(process.cwd(), '.auth');
|
||
if (!existsSync(authDir)) {
|
||
await mkdir(authDir, { recursive: true });
|
||
}
|
||
|
||
const authState = await context.storageState();
|
||
await writeFile(AUTH_STATE_PATH, JSON.stringify(authState, null, 2));
|
||
console.log('✅ Authentication state saved');
|
||
}
|
||
|
||
/**
|
||
* Setup browser with authentication
|
||
*/
|
||
async function setupBrowser() {
|
||
const browser = await chromium.launch({
|
||
headless: false, // Show browser for login
|
||
slowMo: 500 // Slow down actions for visibility
|
||
});
|
||
|
||
const context = await browser.newContext({
|
||
viewport: { width: 1280, height: 720 },
|
||
locale: 'en-CA',
|
||
timezoneId: 'America/Toronto',
|
||
});
|
||
|
||
// Try to load saved auth state
|
||
const savedAuth = await loadAuthState();
|
||
if (savedAuth) {
|
||
console.log('📦 Loading saved authentication state...');
|
||
await context.addCookies(savedAuth.cookies);
|
||
await context.addInitScript(() => {
|
||
// Restore localStorage if needed
|
||
if (window.localStorage) {
|
||
Object.keys(savedAuth.origins?.[0]?.localStorage || {}).forEach(key => {
|
||
window.localStorage.setItem(key, savedAuth.origins[0].localStorage[key]);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return { browser, context };
|
||
}
|
||
|
||
/**
|
||
* Ensure user is logged into Google
|
||
*/
|
||
async function ensureLoggedIn(page) {
|
||
await page.goto('https://www.google.com/alerts');
|
||
await page.waitForLoadState('networkidle');
|
||
|
||
// Check if we need to log in
|
||
const signInButton = page.getByText('Sign in', { exact: false }).first();
|
||
const isVisible = await signInButton.isVisible().catch(() => false);
|
||
|
||
if (isVisible) {
|
||
console.log('🔐 Please log in to Google in the browser window...');
|
||
console.log(' Waiting for you to complete login...');
|
||
|
||
// Wait for user to navigate away and back (login process)
|
||
await page.waitForURL('**/alerts**', { timeout: 300000 }); // 5 min timeout
|
||
|
||
// Wait a bit more to ensure we're fully logged in
|
||
await page.waitForTimeout(2000);
|
||
|
||
console.log('✅ Login detected');
|
||
} else {
|
||
console.log('✅ Already logged in');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a single Google Alert
|
||
*/
|
||
async function createAlert(page, alert) {
|
||
console.log(`\n📝 Creating alert: ${alert.name}`);
|
||
console.log(` Query: ${alert.query.substring(0, 60)}...`);
|
||
|
||
try {
|
||
// Navigate to alerts page
|
||
await page.goto('https://www.google.com/alerts');
|
||
await page.waitForLoadState('networkidle');
|
||
|
||
// Wait for the search input
|
||
const searchInput = page.locator('input[type="text"]').first();
|
||
await searchInput.waitFor({ state: 'visible', timeout: 10000 });
|
||
|
||
// Clear and fill the query
|
||
await searchInput.clear();
|
||
await searchInput.fill(alert.query);
|
||
await page.waitForTimeout(500);
|
||
|
||
// Click "Show options" to expand settings
|
||
const showOptions = page.getByText('Show options', { exact: false }).first();
|
||
if (await showOptions.isVisible()) {
|
||
await showOptions.click();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
// Configure settings - these selectors may need adjustment
|
||
// How often: As-it-happens
|
||
const frequencySelect = page.locator('select').first();
|
||
if (await frequencySelect.isVisible()) {
|
||
await frequencySelect.selectOption('0'); // As-it-happens
|
||
}
|
||
|
||
// Sources: Automatic
|
||
const sourcesSelect = page.locator('select').nth(1);
|
||
if (await sourcesSelect.isVisible()) {
|
||
await sourcesSelect.selectOption('automatic');
|
||
}
|
||
|
||
// Language: English
|
||
const languageSelect = page.locator('select').nth(2);
|
||
if (await languageSelect.isVisible()) {
|
||
await languageSelect.selectOption('en');
|
||
}
|
||
|
||
// Region: Canada
|
||
const regionSelect = page.locator('select').nth(3);
|
||
if (await regionSelect.isVisible()) {
|
||
await regionSelect.selectOption('ca');
|
||
}
|
||
|
||
// How many: All results
|
||
const howManySelect = page.locator('select').nth(4);
|
||
if (await howManySelect.isVisible()) {
|
||
await howManySelect.selectOption('all');
|
||
}
|
||
|
||
// Deliver to: RSS feed
|
||
const rssOption = page.getByText('RSS feed', { exact: false }).first();
|
||
if (await rssOption.isVisible()) {
|
||
await rssOption.click();
|
||
}
|
||
|
||
await page.waitForTimeout(500);
|
||
|
||
// Click "Create Alert"
|
||
const createButton = page.getByRole('button', { name: /Create Alert/i }).first();
|
||
await createButton.click();
|
||
|
||
// Wait for alert to be created
|
||
await page.waitForLoadState('networkidle');
|
||
await page.waitForTimeout(2000);
|
||
|
||
// Try to find and click RSS icon/link
|
||
// The RSS feed URL might be in different places depending on Google's UI
|
||
let rssUrl = null;
|
||
|
||
// Method 1: Look for RSS icon/link in the alerts list
|
||
const rssLink = page.locator('a[href*="feed"]').first();
|
||
const rssLinkVisible = await rssLink.isVisible().catch(() => false);
|
||
|
||
if (rssLinkVisible) {
|
||
const href = await rssLink.getAttribute('href');
|
||
if (href && href.includes('feed')) {
|
||
rssUrl = href.startsWith('http') ? href : `https://www.google.com${href}`;
|
||
}
|
||
}
|
||
|
||
// Method 2: Check if we're on a feed page
|
||
if (!rssUrl && page.url().includes('feed')) {
|
||
rssUrl = page.url();
|
||
}
|
||
|
||
// Method 3: Look for feed URL in page content
|
||
if (!rssUrl) {
|
||
const feedMatch = await page.content().then(content => {
|
||
const match = content.match(/https?:\/\/[^"'\s]*feed[^"'\s]*/i);
|
||
return match ? match[0] : null;
|
||
});
|
||
if (feedMatch) {
|
||
rssUrl = feedMatch;
|
||
}
|
||
}
|
||
|
||
if (rssUrl) {
|
||
console.log(` ✅ RSS Feed: ${rssUrl}`);
|
||
return { success: true, rssUrl, alertName: alert.name };
|
||
} else {
|
||
console.log(` ⚠️ Alert created but RSS URL not found automatically`);
|
||
console.log(` 💡 You may need to manually get the RSS URL from the alerts page`);
|
||
return { success: true, rssUrl: null, alertName: alert.name, needsManualCheck: true };
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(` ❌ Error creating alert: ${error.message}`);
|
||
return { success: false, error: error.message, alertName: alert.name };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load existing RSS feeds
|
||
*/
|
||
async function loadRssFeeds() {
|
||
if (existsSync(RSS_FEEDS_PATH)) {
|
||
const content = await readFile(RSS_FEEDS_PATH, 'utf-8');
|
||
return JSON.parse(content);
|
||
}
|
||
return { alerts: [] };
|
||
}
|
||
|
||
/**
|
||
* Save RSS feeds
|
||
*/
|
||
async function saveRssFeeds(feeds) {
|
||
await writeFile(RSS_FEEDS_PATH, JSON.stringify(feeds, null, 2));
|
||
console.log(`\n💾 RSS feeds saved to ${RSS_FEEDS_PATH}`);
|
||
}
|
||
|
||
/**
|
||
* Main execution
|
||
*/
|
||
async function main() {
|
||
const markdownFile = process.argv[2];
|
||
|
||
if (!markdownFile) {
|
||
console.error('Usage: node scripts/setup-alerts-automated.js <markdown-file>');
|
||
console.error('Example: node scripts/setup-alerts-automated.js docs/google-alerts-reddit-tuned.md');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!existsSync(markdownFile)) {
|
||
console.error(`Error: File not found: ${markdownFile}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log('📖 Parsing alerts from markdown file...');
|
||
const alerts = await parseAlertsFromMarkdown(markdownFile);
|
||
console.log(`✅ Found ${alerts.length} alerts to create\n`);
|
||
|
||
if (alerts.length === 0) {
|
||
console.error('No alerts found in file');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Load existing RSS feeds
|
||
const rssFeeds = await loadRssFeeds();
|
||
const existingAlerts = new Set(rssFeeds.alerts.map(a => a.name));
|
||
|
||
// Filter out already created alerts
|
||
const newAlerts = alerts.filter(alert => !existingAlerts.has(alert.name));
|
||
|
||
if (newAlerts.length === 0) {
|
||
console.log('✅ All alerts already created!');
|
||
return;
|
||
}
|
||
|
||
console.log(`📋 Will create ${newAlerts.length} new alerts\n`);
|
||
|
||
// Setup browser
|
||
const { browser, context } = await setupBrowser();
|
||
const page = await context.newPage();
|
||
|
||
try {
|
||
// Ensure logged in
|
||
await ensureLoggedIn(page);
|
||
|
||
// Save auth state after login
|
||
await saveAuthState(context);
|
||
|
||
// Create alerts one at a time
|
||
const results = [];
|
||
for (let i = 0; i < newAlerts.length; i++) {
|
||
const alert = newAlerts[i];
|
||
console.log(`\n[${i + 1}/${newAlerts.length}]`);
|
||
|
||
const result = await createAlert(page, alert);
|
||
results.push(result);
|
||
|
||
// Add delay between alerts to avoid rate limiting
|
||
if (i < newAlerts.length - 1) {
|
||
console.log(' ⏳ Waiting 3 seconds before next alert...');
|
||
await page.waitForTimeout(3000);
|
||
}
|
||
}
|
||
|
||
// Collect RSS feeds
|
||
const successful = results.filter(r => r.success && r.rssUrl);
|
||
const needsManual = results.filter(r => r.success && !r.rssUrl);
|
||
const failed = results.filter(r => !r.success);
|
||
|
||
// Update RSS feeds file
|
||
successful.forEach(result => {
|
||
rssFeeds.alerts.push({
|
||
name: result.alertName,
|
||
rssUrl: result.rssUrl,
|
||
createdAt: new Date().toISOString()
|
||
});
|
||
});
|
||
|
||
// Add placeholders for manual checks
|
||
needsManual.forEach(result => {
|
||
rssFeeds.alerts.push({
|
||
name: result.alertName,
|
||
rssUrl: 'MANUAL_CHECK_NEEDED',
|
||
createdAt: new Date().toISOString(),
|
||
note: 'RSS URL needs to be retrieved manually'
|
||
});
|
||
});
|
||
|
||
await saveRssFeeds(rssFeeds);
|
||
|
||
// Summary
|
||
console.log('\n' + '='.repeat(60));
|
||
console.log('📊 Summary:');
|
||
console.log(` ✅ Successfully created: ${successful.length}`);
|
||
console.log(` ⚠️ Needs manual RSS URL: ${needsManual.length}`);
|
||
console.log(` ❌ Failed: ${failed.length}`);
|
||
console.log('='.repeat(60));
|
||
|
||
if (needsManual.length > 0) {
|
||
console.log('\n⚠️ Alerts that need manual RSS URL retrieval:');
|
||
needsManual.forEach(r => console.log(` - ${r.alertName}`));
|
||
}
|
||
|
||
if (failed.length > 0) {
|
||
console.log('\n❌ Failed alerts:');
|
||
failed.forEach(r => console.log(` - ${r.alertName}: ${r.error}`));
|
||
}
|
||
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
main().catch(error => {
|
||
console.error('Fatal error:', error);
|
||
process.exit(1);
|
||
});
|
||
|