New workflow; factor in Lapsed, and new header structure
This commit is contained in:
parent
a297967d81
commit
4b561bc934
37
README.md
37
README.md
@ -154,3 +154,40 @@ This may be freely redistributed under the terms of the GNU General Public Licen
|
|||||||
|
|
||||||
You will want to send results to a file.
|
You will want to send results to a file.
|
||||||
Example: ./google-civic-api.pl google '*.csv' > 2023-districts
|
Example: ./google-civic-api.pl google '*.csv' > 2023-districts
|
||||||
|
|
||||||
|
## Automation
|
||||||
|
|
||||||
|
### subscribe-members.sh
|
||||||
|
|
||||||
|
Batch subscription example using [google-civic-api.pl](https://git.bikeshopi.dev/bike/lwvwv) to extract member data from LWVWV membership CSV files. This script processes membership exports and automatically subscribes members to mailing lists.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- `google-civic-api.pl` must be in the same directory or PATH
|
||||||
|
- Membership CSV file from LWVWV portal
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
./subscribe-members.sh <membership_csv_file> [list_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Using default list
|
||||||
|
./subscribe-members.sh league_membership.csv
|
||||||
|
|
||||||
|
# Using specific list
|
||||||
|
./subscribe-members.sh league_membership.csv test.lists.example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Runs `./google-civic-api.pl ALL <membership_file> email-csv` to extract emails
|
||||||
|
2. Converts output to CSV format: `email,first_name,last_name`
|
||||||
|
3. Sends batch subscribe request to connector
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
Edit the script to set:
|
||||||
|
```bash
|
||||||
|
CONNECTOR_URL="https://mailman-connector.example.com"
|
||||||
|
CONNECTOR_PASSWORD="your_secret_password"
|
||||||
|
LIST_ID="test.lists.example.org" # Default list
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ function sendErrorEmail(errorMessage) {
|
|||||||
|
|
||||||
const hostname = require('os').hostname();
|
const hostname = require('os').hostname();
|
||||||
const subject = `LWV Roster Download Failed on: ${hostname}`;
|
const subject = `LWV Roster Download Failed on: ${hostname}`;
|
||||||
const body = `Error: ${errorMessage}\n\nTo fix this, please run: node save-session.js\n\nThen log in using the magic link method to refresh your session.`;
|
const body = `Error: ${errorMessage}\n\nTo fix this, please run: node save-session.js\n\nThen log in using the magic link method to refresh your session. If error persists, download-roster.js may need to be updated to relect changes on the portal page.`;
|
||||||
|
|
||||||
// Build s-nail command - match user's working format exactly
|
// Build s-nail command - match user's working format exactly
|
||||||
// Note: smtp value needs quotes because it contains ":"
|
// Note: smtp value needs quotes because it contains ":"
|
||||||
@ -184,29 +184,95 @@ async function downloadRoster() {
|
|||||||
console.log('Looking for Export button...');
|
console.log('Looking for Export button...');
|
||||||
await page.screenshot({ path: path.join(DOWNLOAD_DIR, 'membership-page.png') });
|
await page.screenshot({ path: path.join(DOWNLOAD_DIR, 'membership-page.png') });
|
||||||
|
|
||||||
// Try to find Export button/link
|
// Try to find Export button (using ID for reliability)
|
||||||
const exportBtn = await page.locator('button:has-text("Export")').first();
|
console.log('Looking for Export button...');
|
||||||
|
await page.screenshot({ path: path.join(DOWNLOAD_DIR, 'membership-page.png') });
|
||||||
|
|
||||||
|
const exportBtn = await page.locator('#league_membership_state_view-export-trigger, button:has-text("Export")').first();
|
||||||
|
|
||||||
if (await exportBtn.count() > 0) {
|
if (await exportBtn.count() > 0) {
|
||||||
console.log('Clicking Export...');
|
// First, try to extract download URL directly from HTML (in case export was already generated)
|
||||||
await exportBtn.click();
|
console.log('Checking for existing download URL in page HTML...');
|
||||||
await page.waitForTimeout(3000);
|
const pageContent = await page.content();
|
||||||
|
const downloadUrlMatch = pageContent.match(/href="(\/file\/download\/table_export[^"]+)"/);
|
||||||
|
let downloadUrl = downloadUrlMatch ? downloadUrlMatch[1] : null;
|
||||||
|
|
||||||
|
if (downloadUrl) {
|
||||||
|
console.log('Found existing download URL in HTML:', downloadUrl);
|
||||||
|
} else {
|
||||||
|
// Two-step export process:
|
||||||
|
// Step 1: Click Export button to open modal
|
||||||
|
console.log('Clicking Export button to open modal...');
|
||||||
|
await exportBtn.scrollIntoViewIfNeeded();
|
||||||
|
await exportBtn.click({ force: true });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Step 2: Wait for and click "Start Export" button in modal
|
||||||
|
console.log('Looking for Start Export button in modal...');
|
||||||
|
const startExportBtn = await page.locator('button[type="submit"]:has-text("Start Export"), button:has-text("Start Export")').first();
|
||||||
|
|
||||||
|
if (await startExportBtn.count() > 0) {
|
||||||
|
console.log('Clicking Start Export button...');
|
||||||
|
await startExportBtn.click({ force: true });
|
||||||
|
|
||||||
|
// Step 3: Wait for export status section to appear
|
||||||
|
console.log('Waiting for export to complete...');
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('#league_membership_state_view-status-completed', {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
console.log('Export status section is now visible');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Timeout waiting for export status section...');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
} else {
|
||||||
|
console.log('Start Export button not found, trying alternative methods...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract URL from updated HTML
|
||||||
|
console.log('Checking for download URL after export...');
|
||||||
|
const updatedContent = await page.content();
|
||||||
|
const updatedMatch = updatedContent.match(/href="(\/file\/download\/table_export[^"]+)"/);
|
||||||
|
if (updatedMatch) {
|
||||||
|
downloadUrl = updatedMatch[1];
|
||||||
|
console.log('Found download URL:', downloadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still no URL, try to get it from the visible download link
|
||||||
|
if (!downloadUrl) {
|
||||||
|
const downloadLink = await page.locator('#league_membership_state_view-status-completed a[href*="/file/download"]').first();
|
||||||
|
if (await downloadLink.count() > 0) {
|
||||||
|
downloadUrl = await downloadLink.getAttribute('href');
|
||||||
|
console.log('Found download URL from link element:', downloadUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(DOWNLOAD_DIR, 'after-export-click.png') });
|
await page.screenshot({ path: path.join(DOWNLOAD_DIR, 'after-export-click.png') });
|
||||||
|
|
||||||
// Look for download button
|
if (downloadUrl) {
|
||||||
const downloadBtn = await page.locator('input[type="submit"][value="Download"]').first();
|
// Ensure URL is absolute
|
||||||
if (await downloadBtn.count() > 0) {
|
const fullUrl = downloadUrl.startsWith('http') ? downloadUrl : `https://portal.lwv.org${downloadUrl}`;
|
||||||
console.log('Clicking Download...');
|
console.log('Download URL:', fullUrl);
|
||||||
|
|
||||||
const downloadPromise = page.waitForEvent('download', { timeout: 30000 });
|
// Use Playwright's request API (automatically includes cookies)
|
||||||
await downloadBtn.click();
|
console.log('Downloading via Playwright request API...');
|
||||||
|
const response = await page.request.get(fullUrl);
|
||||||
|
|
||||||
const download = await downloadPromise;
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Download failed with status: ${response.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save file
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
const filename = `league_membership_state_view_${timestamp}.csv`;
|
const filename = `league_membership_state_view_${timestamp}.csv`;
|
||||||
const filepath = path.join(DOWNLOAD_DIR, filename);
|
const filepath = path.join(DOWNLOAD_DIR, filename);
|
||||||
|
|
||||||
await download.saveAs(filepath);
|
const buffer = await response.body();
|
||||||
|
fs.writeFileSync(filepath, buffer);
|
||||||
console.log(`Downloaded: ${filename}`);
|
console.log(`Downloaded: ${filename}`);
|
||||||
|
|
||||||
// Verify the file
|
// Verify the file
|
||||||
@ -215,8 +281,9 @@ async function downloadRoster() {
|
|||||||
console.log(`File size: ${stats.size} bytes`);
|
console.log(`File size: ${stats.size} bytes`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = 'Download button not found in export modal';
|
const error = 'Download link not found after clicking Export';
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
await page.screenshot({ path: path.join(DOWNLOAD_DIR, 'download-not-found.png') });
|
||||||
sendErrorEmail(error);
|
sendErrorEmail(error);
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,8 +72,14 @@ else {
|
|||||||
}
|
}
|
||||||
|
|
||||||
my $emailArg;
|
my $emailArg;
|
||||||
|
my $emailCsvArg;
|
||||||
if ( $ARGV[2] ) {
|
if ( $ARGV[2] ) {
|
||||||
$emailArg = $ARGV[2];
|
if ( $ARGV[2] eq 'email-csv' ) {
|
||||||
|
$emailCsvArg = 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$emailArg = $ARGV[2];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Open roster file .. hopefully columns remain the same,
|
# Open roster file .. hopefully columns remain the same,
|
||||||
@ -91,20 +97,20 @@ foreach my $file (@files) {
|
|||||||
# Read the first line
|
# Read the first line
|
||||||
my $localLeague;
|
my $localLeague;
|
||||||
my $header = <$fh>;
|
my $header = <$fh>;
|
||||||
print "Header: $header"; # Output the header for reference
|
print "Header: $header" unless $emailCsvArg; # Output the header for reference
|
||||||
# Decide how to process
|
# Decide how to process
|
||||||
if ( $header !~ /League ID/ ) {
|
if ( $header !~ /League ID/ ) {
|
||||||
print "Processing file: $file ... No League ID found, must be MAL\n";
|
print "Processing file: $file ... No League ID found, must be MAL\n" unless $emailCsvArg;
|
||||||
$localLeague = 0; # This is a Members At Large (MAL) file
|
$localLeague = 0; # This is a Members At Large (MAL) file
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
print "Processing file: $file ... League ID found\n";
|
print "Processing file: $file ... League ID found\n" unless $emailCsvArg;
|
||||||
$localLeague = 1; # This is local League file
|
$localLeague = 1; # This is local League file
|
||||||
}
|
}
|
||||||
|
|
||||||
my $csv =
|
my $csv =
|
||||||
"Name,Email,Phone,Address,Delegate District,Senate District,League ID,Join Date\n";
|
"Name,Email,Phone,Address,Delegate District,Senate District,League ID,Join Date\n";
|
||||||
print $csv;
|
print $csv unless $emailCsvArg;
|
||||||
$csv = "";
|
$csv = "";
|
||||||
|
|
||||||
while ( my $line = <$fh> ) {
|
while ( my $line = <$fh> ) {
|
||||||
@ -120,32 +126,34 @@ foreach my $file (@files) {
|
|||||||
if ($localLeague) {
|
if ($localLeague) {
|
||||||
|
|
||||||
# Local League file processing
|
# Local League file processing
|
||||||
# League ID is in column 11
|
# Updated for new CSV header format (as of 2026-05-02)
|
||||||
( $leagueId = $fields[11] ) =~ s/"//g; # League ID
|
# First Name[0],Preferred First Name[1],League Name[2],League ID[3],Status[4],Joined[5],Expiration[6],Unique Contact Id[7],Unique Account Id[8],Last Name[9],Email[10],Phone[11],Mailing Street[12],Mailing City[13],Mailing State[14],Mailing Postal Code[15],Mailing Country[16]
|
||||||
( $phone = $fields[4] ) =~ s/"//g; # Phone
|
( $leagueId = $fields[3] ) =~ s/"//g; # League ID [was 11]
|
||||||
( $email = $fields[3] ) =~ s/"//g; # Email
|
( $phone = $fields[11] ) =~ s/"//g; # Phone [was 4]
|
||||||
( $joinDate = $fields[13] ) =~ s/"//g; # Join Date
|
( $email = $fields[10] ) =~ s/"//g; # Email [was 3]
|
||||||
( $status = $fields[12] ) =~ s/"//g; # Status - field [12] for Local League
|
( $joinDate = $fields[5] ) =~ s/"//g; # Join Date [was 13]
|
||||||
|
( $status = $fields[4] ) =~ s/"//g; # Status [was 12]
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
# Members At Large (MAL) file processing
|
# Members At Large (MAL) file processing
|
||||||
|
# Updated for new CSV header format (as of 2026-05-02)
|
||||||
# League ID is assumed to be WV000 for all MALs
|
# League ID is assumed to be WV000 for all MALs
|
||||||
$leagueId = $stateLeagueID; # Default for MALs
|
$leagueId = $stateLeagueID; # Default for MALs
|
||||||
( $phone = $fields[3] ) =~ s/"//g; # Phone
|
( $phone = $fields[11] ) =~ s/"//g; # Phone [was 3]
|
||||||
( $email = $fields[4] ) =~ s/"//g; # Email
|
( $email = $fields[10] ) =~ s/"//g; # Email [was 4]
|
||||||
( $joinDate = $fields[10] ) =~ s/"//g; # Join Date
|
( $joinDate = $fields[5] ) =~ s/"//g; # Join Date [was 10]
|
||||||
( $status = $fields[13] ) =~ s/"//g; # Status - field [13] for MAL files
|
( $status = $fields[4] ) =~ s/"//g; # Status [was 13]
|
||||||
}
|
}
|
||||||
( my $firstName = $fields[0] ) =~ s/"//g; # First Name
|
( my $firstName = $fields[0] ) =~ s/"//g; # First Name [unchanged]
|
||||||
( my $lastName = $fields[2] ) =~ s/"//g; # Last Name
|
( my $lastName = $fields[9] ) =~ s/"//g; # Last Name [was 2]
|
||||||
( my $street = $fields[5] ) =~ s/"//g; # Street
|
( my $street = $fields[12] ) =~ s/"//g; # Street [was 5]
|
||||||
( my $city = $fields[6] ) =~ s/"//g; # City
|
( my $city = $fields[13] ) =~ s/"//g; # City [was 6]
|
||||||
( my $state = $fields[7] ) =~ s/"//g; # State
|
( my $state = $fields[14] ) =~ s/"//g; # State [was 7]
|
||||||
( my $zip = $fields[8] ) =~ s/"//g; # Zip
|
( my $zip = $fields[15] ) =~ s/"//g; # Zip [was 8]
|
||||||
next if $street eq "Mailing Street";
|
next if $street eq "Mailing Street";
|
||||||
|
|
||||||
# Filter for Primary status only (Primary, Primary - Life, etc.)
|
# Filter for Primary status only (Primary, Primary - Life, etc.)
|
||||||
next unless $status =~ /^Primary.*/;
|
next unless $status =~ /^Primary.*|Lapsed/;
|
||||||
|
|
||||||
if ( $leagueId !~ /^$STATE/ ) {
|
if ( $leagueId !~ /^$STATE/ ) {
|
||||||
next;
|
next;
|
||||||
@ -162,7 +170,20 @@ foreach my $file (@files) {
|
|||||||
|
|
||||||
if ( $status ne "Inactive" ) {
|
if ( $status ne "Inactive" ) {
|
||||||
|
|
||||||
if ( !$emailArg ) {
|
if ( $emailCsvArg ) {
|
||||||
|
# CSV format: email,first_name,last_name
|
||||||
|
if ( email($email) ) {
|
||||||
|
print "$email,$firstName,$lastName\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elsif ( $emailArg ) {
|
||||||
|
# Original email format: First Last <email>
|
||||||
|
if ( email($email) ) {
|
||||||
|
print "$firstName $lastName <$email>\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Full CSV output with all fields
|
||||||
|
|
||||||
# contact for people with email address
|
# contact for people with email address
|
||||||
if ( email($email) ) {
|
if ( email($email) ) {
|
||||||
@ -277,11 +298,6 @@ foreach my $file (@files) {
|
|||||||
|
|
||||||
print $csv . "\n";
|
print $csv . "\n";
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
if ( email($email) ) {
|
|
||||||
print "$firstName $lastName <$email>\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close $fh;
|
close $fh;
|
||||||
@ -327,6 +343,7 @@ generate an information file or email file.
|
|||||||
Usage: $0 google '*.csv' (queries Google Civic Api Delegate and Senate District for all LWVWV members)
|
Usage: $0 google '*.csv' (queries Google Civic Api Delegate and Senate District for all LWVWV members)
|
||||||
$0 ${STATE}000 '*.csv' (show all information for members at large, but do not query Google)
|
$0 ${STATE}000 '*.csv' (show all information for members at large, but do not query Google)
|
||||||
$0 ${STATE}000 '*.csv' email (only show email addresses for members at large, but do not query Google)
|
$0 ${STATE}000 '*.csv' email (only show email addresses for members at large, but do not query Google)
|
||||||
|
$0 ${STATE}000 '*.csv' email-csv (only show email addresses in CSV format for members at large, but do not query Google)
|
||||||
|
|
||||||
1st argument can be one of these types:
|
1st argument can be one of these types:
|
||||||
google (all members with senate/delegate district query) which prints out this csv data:
|
google (all members with senate/delegate district query) which prints out this csv data:
|
||||||
@ -338,6 +355,7 @@ Usage: $0 google '*.csv' (queries Google Civic Api Delegate and Senate Distric
|
|||||||
Name,Email,Phone,Address,Join Date,
|
Name,Email,Phone,Address,Join Date,
|
||||||
|
|
||||||
3rd argument 'email' will only print out the email addresses, and only works with the ALL or League ID type argument
|
3rd argument 'email' will only print out the email addresses, and only works with the ALL or League ID type argument
|
||||||
|
3rd argument 'email-csv' will print out email addresses in CSV format (email,first_name,last_name), and only works with the ALL or League ID type argument
|
||||||
|
|
||||||
|
|
||||||
You will want to send results to a file.
|
You will want to send results to a file.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user