Files
MyKeyser/scripts/facebook-events-import.php
T
Ty Clifford 354bb4255d - Rebuilt the CLI importer to use the official Facebook Graph API instead of HTML scraping.
Main changes:
Replaced 
[scripts/facebook-events-import.php](/Users/tyemeclifford/Documents/GH/MyKeyser/scripts/facebook-events-import.php) 
with a Graph API client.
It now supports --page-id, --pages-file, --event-id, --events-file, and 
optional area discovery via --discover-pages --center=LAT,LNG 
--query=....
Uses FACEBOOK_GRAPH_ACCESS_TOKEN or --access-token.
Pulls Graph fields for title, description, start/end time, 
place/location, ticket URI, and cover.source for the main photo.
Keeps the existing staged review/import workflow in 
/admin/event_imports.php.

- Implemented event detail pages and clickable event cards.
What changed:
Added [event.php](/Users/tyemeclifford/Documents/GH/MyKeyser/event.php) 
for individual event pages.
Made event cards clickable from 
[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php), 
[index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/index.php), and 
legacy 
[2index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/2index.php).
Added large event photo display plus lightbox enlargement in 
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css) 
and 
[assets/js/app.js](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/js/app.js).
Added eventUrl() and externalHref() helpers in 
[includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php).
Added copy-event-link support on event detail pages.
Updated README to list the new route.
2026-06-19 18:07:45 -04:00

330 lines
14 KiB
PHP

#!/usr/bin/env php
<?php
require_once dirname(__DIR__).'/includes/boot.php';
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
const FB_GRAPH_DEFAULT_VERSION = 'v23.0';
const FB_GRAPH_EVENT_FIELDS = 'id,name,description,start_time,end_time,place{id,name,location},cover{source},ticket_uri';
const FB_GRAPH_PLACE_FIELDS = 'id,name,location';
function fbCliUsage(): void {
echo "Facebook Graph API event importer for My Keyser\n\n";
echo "Usage:\n";
echo " FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area=\"Keyser, WV\" --page-id=123456789\n";
echo " php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345\n";
echo " php scripts/facebook-events-import.php --access-token=... --pages-file=/path/to/page-ids.txt\n";
echo " php scripts/facebook-events-import.php --access-token=... --area=\"Keyser, WV\" --center=39.4364,-78.9762 --distance=25000 --query=\"Keyser\" --discover-pages\n\n";
echo "Required:\n";
echo " --access-token=TOKEN Graph API access token, or set FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n";
echo "Sources:\n";
echo " --page-id=ID[,ID] Facebook Page IDs to read through /{page-id}/events.\n";
echo " --pages-file=FILE Plain-text Page ID file, one ID per line.\n";
echo " --event-id=ID[,ID] Specific Facebook Event IDs to read through /{event-id}.\n";
echo " --events-file=FILE Plain-text Event ID file, one ID per line.\n";
echo " --discover-pages Use Graph place search for --center/--distance/--query, then read events for matching Page/place IDs.\n\n";
echo "Options:\n";
echo " --area=TEXT Label shown in the admin import review queue.\n";
echo " --center=LAT,LNG Center point for Graph place search.\n";
echo " --distance=METERS Radius for Graph place search. Default: 25000.\n";
echo " --query=TEXT Place search query. Defaults to --area.\n";
echo " --place-limit=N Maximum places to discover. Default: 50.\n";
echo " --limit=N Maximum events to stage total. Default: 100.\n";
echo " --since=YYYY-MM-DD Start date for Page event reads. Default: today.\n";
echo " --until=YYYY-MM-DD End date for Page event reads. Default: +1 year.\n";
echo " --graph-version=vNN.0 Graph API version. Default: ".FB_GRAPH_DEFAULT_VERSION." or FACEBOOK_GRAPH_VERSION.\n";
echo " --no-images Stage Graph cover URLs but skip downloading cover photos.\n";
echo " --dry-run Fetch and print candidates without writing to the database.\n";
echo " --help Show this help.\n\n";
echo "Notes:\n";
echo " This script uses only official Graph API endpoints. It does not scrape HTML, log in,\n";
echo " solve challenges, or bypass Facebook access controls. Your Meta app/token must have\n";
echo " permission to read the requested Page or Event data.\n";
}
function fbCliOptions(array $argv): array {
$options = [];
for ($i = 1; $i < count($argv); $i++) {
$arg = $argv[$i];
if (!str_starts_with($arg, '--')) continue;
$arg = substr($arg, 2);
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
} else {
$key = $arg;
$value = true;
if ($i + 1 < count($argv) && !str_starts_with($argv[$i + 1], '--')) $value = $argv[++$i];
}
if (isset($options[$key])) {
if (!is_array($options[$key])) $options[$key] = [$options[$key]];
$options[$key][] = $value;
} else {
$options[$key] = $value;
}
}
return $options;
}
function fbCliValues(array $options, string $key): array {
if (!isset($options[$key])) return [];
$value = $options[$key];
if (!is_array($value)) $value = [$value];
$out = [];
foreach ($value as $item) {
foreach (explode(',', (string)$item) as $part) {
$part = trim($part);
if ($part !== '') $out[] = $part;
}
}
return $out;
}
function fbCliFileValues(array $options, string $key): array {
$out = [];
foreach (fbCliValues($options, $key) as $file) {
if (!is_file($file) || !is_readable($file)) {
throw new RuntimeException("File is not readable: ".$file);
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim(preg_replace('/#.*/', '', $line));
if ($line !== '') $out[] = $line;
}
}
return $out;
}
function fbCliLine(string $message = ''): void {
echo $message."\n";
}
function fbCliGraphVersion(array $options): string {
$version = trim((string)($options['graph-version'] ?? getenv('FACEBOOK_GRAPH_VERSION') ?: FB_GRAPH_DEFAULT_VERSION));
if ($version === '') $version = FB_GRAPH_DEFAULT_VERSION;
return str_starts_with($version, 'v') ? $version : 'v'.$version;
}
function fbCliGraphBase(string $version): string {
return 'https://graph.facebook.com/'.rawurlencode($version).'/';
}
function fbCliGraphRequest(string $path, array $params, string $token, string $version): array {
$path = ltrim($path, '/');
$url = fbCliGraphBase($version).$path;
if ($params) $url .= '?'.http_build_query($params);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 4,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer '.$token,
],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if (PHP_VERSION_ID < 80000) curl_close($ch);
if ($errno) throw new RuntimeException($error ?: 'Graph API request failed.');
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 30,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nAuthorization: Bearer ".$token."\r\nUser-Agent: ".facebookEventImporterUserAgent()."\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
$statusLine = $http_response_header[0] ?? '';
$status = preg_match('/\s(\d{3})\s/', $statusLine, $match) ? (int)$match[1] : 0;
if ($body === false) throw new RuntimeException($statusLine ?: 'Graph API request failed.');
}
$json = json_decode((string)$body, true);
if (!is_array($json)) throw new RuntimeException('Graph API returned invalid JSON.');
if ($status < 200 || $status >= 300 || isset($json['error'])) {
$err = $json['error'] ?? [];
$message = is_array($err) ? ($err['message'] ?? 'Graph API error') : 'Graph API error';
$code = is_array($err) && isset($err['code']) ? ' (code '.$err['code'].')' : '';
throw new RuntimeException($message.$code);
}
return $json;
}
function fbCliDateToTimestamp(string $date, string $fallback): int {
$date = trim($date);
$ts = strtotime($date !== '' ? $date : $fallback);
if ($ts === false) throw new RuntimeException("Invalid date: ".$date);
return $ts;
}
function fbCliDiscoverPages(array $options, string $token, string $version): array {
if (empty($options['discover-pages'])) return [];
$center = trim((string)($options['center'] ?? ''));
if ($center === '' || !preg_match('/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/', $center)) {
throw new RuntimeException('--discover-pages requires --center=LAT,LNG.');
}
$area = trim((string)($options['area'] ?? ''));
$query = trim((string)($options['query'] ?? $area));
if ($query === '') throw new RuntimeException('--discover-pages requires --query or --area.');
$limit = max(1, min(100, (int)($options['place-limit'] ?? 50)));
$distance = max(1, (int)($options['distance'] ?? 25000));
$json = fbCliGraphRequest('search', [
'type' => 'place',
'q' => $query,
'center' => $center,
'distance' => $distance,
'fields' => FB_GRAPH_PLACE_FIELDS,
'limit' => $limit,
], $token, $version);
$ids = [];
foreach (($json['data'] ?? []) as $place) {
if (!empty($place['id'])) $ids[(string)$place['id']] = (string)$place['id'];
}
fbCliLine('Discovered '.count($ids).' Page/place ID'.(count($ids) === 1 ? '' : 's').' from Graph place search.');
return array_values($ids);
}
function fbCliFetchPageEvents(string $pageId, string $token, string $version, int $since, int $until, int $remaining): array {
$events = [];
$after = '';
while ($remaining > count($events)) {
$params = [
'fields' => FB_GRAPH_EVENT_FIELDS,
'since' => $since,
'until' => $until,
'limit' => min(100, $remaining - count($events)),
];
if ($after !== '') $params['after'] = $after;
$json = fbCliGraphRequest(rawurlencode($pageId).'/events', $params, $token, $version);
foreach (($json['data'] ?? []) as $event) {
if (is_array($event)) $events[] = $event + ['_source_page_id' => $pageId];
}
$after = (string)($json['paging']['cursors']['after'] ?? '');
if ($after === '' || empty($json['data'])) break;
}
return $events;
}
function fbCliFetchEvent(string $eventId, string $token, string $version): array {
$json = fbCliGraphRequest(rawurlencode($eventId), ['fields' => FB_GRAPH_EVENT_FIELDS], $token, $version);
return $json + ['_source_event_id' => $eventId];
}
function fbCliStageGraphEvent(array $event, string $area, bool $downloadImages, bool $dryRun, array &$summary): void {
$candidate = facebookGraphEventToCandidate($event, $area);
$title = $candidate['title'] ?: '(untitled)';
if ($dryRun) {
$summary['dry_run']++;
fbCliLine('DRY RUN: '.$title.' | '.($candidate['event_date'] ?: 'no date').' | '.($candidate['source_url'] ?: 'no source URL'));
return;
}
$result = facebookEventStageCandidate($candidate, $downloadImages);
if ($result['ok']) {
$summary[$result['action']] = ($summary[$result['action']] ?? 0) + 1;
fbCliLine(strtoupper(str_replace('_', ' ', $result['action'])).': #'.$result['id'].' '.$title);
} else {
$summary['failed']++;
fbCliLine('FAILED: '.$title.' - '.$result['error']);
}
}
$options = fbCliOptions($argv);
if (isset($options['help']) || $options === []) {
fbCliUsage();
exit(0);
}
$token = trim((string)($options['access-token'] ?? getenv('FACEBOOK_GRAPH_ACCESS_TOKEN') ?: ''));
if ($token === '') {
fwrite(STDERR, "Missing Graph API access token. Use --access-token or FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n");
fbCliUsage();
exit(2);
}
try {
$version = fbCliGraphVersion($options);
$area = trim((string)($options['area'] ?? ''));
$limit = max(1, (int)($options['limit'] ?? 100));
$downloadImages = !isset($options['no-images']);
$dryRun = isset($options['dry-run']);
$summary = ['created' => 0, 'updated' => 0, 'already_imported' => 0, 'dry_run' => 0, 'failed' => 0, 'graph_failed' => 0];
$pageIds = array_values(array_unique(array_merge(
fbCliValues($options, 'page-id'),
fbCliFileValues($options, 'pages-file'),
fbCliDiscoverPages($options, $token, $version)
)));
$eventIds = array_values(array_unique(array_merge(
fbCliValues($options, 'event-id'),
fbCliFileValues($options, 'events-file')
)));
if (!$pageIds && !$eventIds) {
throw new RuntimeException('Provide at least one --page-id, --pages-file, --event-id, --events-file, or --discover-pages source.');
}
$since = fbCliDateToTimestamp((string)($options['since'] ?? ''), 'today');
$until = fbCliDateToTimestamp((string)($options['until'] ?? ''), '+1 year');
$staged = 0;
foreach ($eventIds as $eventId) {
if ($staged >= $limit) break;
fbCliLine('Fetching Graph event: '.$eventId);
try {
$event = fbCliFetchEvent($eventId, $token, $version);
fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
$staged++;
} catch (Throwable $e) {
$summary['graph_failed']++;
fbCliLine('GRAPH FAILED: event '.$eventId.' - '.$e->getMessage());
}
}
foreach ($pageIds as $pageId) {
if ($staged >= $limit) break;
$remaining = $limit - $staged;
fbCliLine('Fetching Graph Page events: '.$pageId);
try {
$events = fbCliFetchPageEvents($pageId, $token, $version, $since, $until, $remaining);
} catch (Throwable $e) {
$summary['graph_failed']++;
fbCliLine('GRAPH FAILED: page '.$pageId.' - '.$e->getMessage());
continue;
}
foreach ($events as $event) {
if ($staged >= $limit) break;
fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
$staged++;
}
}
fbCliLine();
fbCliLine('Summary:');
fbCliLine(' Created: '.$summary['created']);
fbCliLine(' Updated: '.$summary['updated']);
fbCliLine(' Already imported: '.$summary['already_imported']);
if ($dryRun) fbCliLine(' Dry-run candidates: '.$summary['dry_run']);
fbCliLine(' Stage failures: '.$summary['failed']);
fbCliLine(' Graph failures: '.$summary['graph_failed']);
fbCliLine();
fbCliLine('Review staged imports at: /admin/event_imports.php');
} catch (Throwable $e) {
fwrite(STDERR, "Error: ".$e->getMessage()."\n");
exit(1);
}