From 354bb4255d3704e113104e4e06ad376ee79983ee Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Fri, 19 Jun 2026 18:07:45 -0400 Subject: [PATCH] - 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. --- 2index.php | 4 +- README.md | 18 + admin/admin_header.php | 7 + admin/event_imports.php | 246 +++++++++++ assets/css/style.css | 16 +- assets/js/app.js | 39 ++ event.php | 128 ++++++ events.php | 6 +- includes/db.php | 53 +++ includes/facebook_event_importer.php | 601 +++++++++++++++++++++++++++ includes/functions.php | 13 +- index.php | 4 +- scripts/facebook-events-import.php | 329 +++++++++++++++ 13 files changed, 1455 insertions(+), 9 deletions(-) create mode 100644 admin/event_imports.php create mode 100644 event.php create mode 100644 includes/facebook_event_importer.php create mode 100644 scripts/facebook-events-import.php diff --git a/2index.php b/2index.php index a110c43..384fa43 100644 --- a/2index.php +++ b/2index.php @@ -136,7 +136,7 @@ include __DIR__.'/includes/header.php';
-
+
@@ -151,7 +151,7 @@ include __DIR__.'/includes/header.php';
180?'…':'')?>
- +
All Events diff --git a/README.md b/README.md index 4f8fdb0..1ff2e49 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,24 @@ php -S localhost:8080 - **Alerts** — post/manage business announcements - **Attractions** — full CRUD for attractions - **Events** — approve/reject submissions, add events, feature events +- **Event Imports** — review Facebook event candidates staged by the CLI importer - **Users** — add/edit/delete users, reset passwords, suspend accounts - **Assign Owners** — link users to businesses for self-management - **Reports** — review and close user-submitted listing corrections - **Settings** — toggle registration on/off, event approval, change admin password +### Facebook Event Import Workflow + +Stage Facebook event candidates from the official Facebook Graph API, then review and import them from Admin -> Event Imports: + +```bash +FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20 +php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345 +php scripts/facebook-events-import.php --access-token=... --area="Keyser, WV" --center=39.4364,-78.9762 --distance=25000 --query="Keyser" --discover-pages +``` + +The importer does not scrape Facebook, log in, or bypass access controls. Your Meta app/token must have permission to read the requested Page/Event data through Graph API. + --- ## FILE STRUCTURE @@ -86,6 +99,7 @@ keyser-wv/ ├── menu.php # Owner menu manager ├── attractions.php # Attractions page ├── events.php # Events calendar + submission +├── event.php # Event detail page ├── about.php # About Keyser WV history ├── login.php # Login ├── register.php # Registration @@ -95,6 +109,7 @@ keyser-wv/ ├── includes/ │ ├── boot.php # Bootstrap (session + DB init) │ ├── db.php # Database, schema, seed data +│ ├── facebook_event_importer.php # Facebook event parser/import helpers │ ├── functions.php # Auth, helpers, CSRF, stars │ ├── header.php # HTML header + navbar │ └── footer.php # Footer + JS @@ -107,10 +122,13 @@ keyser-wv/ │ ├── alerts.php # Manage alerts │ ├── attractions.php # Manage attractions │ ├── events.php # Manage events +│ ├── event_imports.php # Review staged Facebook event imports │ ├── users.php # Manage users │ ├── owners.php # Assign owners to businesses │ ├── reports.php # Handle listing reports │ └── settings.php # Site settings + password +├── scripts/ +│ └── facebook-events-import.php # CLI Facebook event stager ├── assets/ │ ├── css/style.css # Complete dark WV theme │ └── js/app.js # Nav, dropdowns, confirm dialogs diff --git a/admin/admin_header.php b/admin/admin_header.php index a382137..4180433 100644 --- a/admin/admin_header.php +++ b/admin/admin_header.php @@ -7,6 +7,7 @@ include dirname(__DIR__).'/includes/header.php'; // Pending owner edit count for badge $_pendingEdits = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'"); $_pendingClaims = (int)qval("SELECT COUNT(*) FROM business_claim_requests WHERE status='pending'"); +$_pendingEventImports = (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'"); ?>
+ 1): ?>
diff --git a/includes/db.php b/includes/db.php index 21f53f1..f441d0c 100644 --- a/includes/db.php +++ b/includes/db.php @@ -95,6 +95,34 @@ function _ensureAppUpgrades(PDO $db): void { created_at TEXT NOT NULL DEFAULT(datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at); + CREATE TABLE IF NOT EXISTS event_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'facebook', + source_area TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', + external_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + venue TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + event_date TEXT NOT NULL DEFAULT '', + event_time TEXT NOT NULL DEFAULT '', + end_date TEXT, + end_time TEXT NOT NULL DEFAULT '', + cost TEXT NOT NULL DEFAULT 'Free', + website TEXT NOT NULL DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', + image_path TEXT NOT NULL DEFAULT '', + raw_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'staged', + status_note TEXT NOT NULL DEFAULT '', + imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_event_imports_status ON event_imports(status, created_at); + CREATE INDEX IF NOT EXISTS idx_event_imports_external ON event_imports(provider, external_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_event_imports_source ON event_imports(provider, source_url) WHERE source_url <> ''; CREATE TABLE IF NOT EXISTS forum_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -329,6 +357,31 @@ function _schema(PDO $db): void { is_featured INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT(datetime('now')) ); + CREATE TABLE IF NOT EXISTS event_imports( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'facebook', + source_area TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', + external_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + venue TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + event_date TEXT NOT NULL DEFAULT '', + event_time TEXT NOT NULL DEFAULT '', + end_date TEXT, + end_time TEXT NOT NULL DEFAULT '', + cost TEXT NOT NULL DEFAULT 'Free', + website TEXT NOT NULL DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', + image_path TEXT NOT NULL DEFAULT '', + raw_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'staged', + status_note TEXT NOT NULL DEFAULT '', + imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) + ); CREATE TABLE IF NOT EXISTS reports( id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, diff --git a/includes/facebook_event_importer.php b/includes/facebook_event_importer.php new file mode 100644 index 0000000..c4a953c --- /dev/null +++ b/includes/facebook_event_importer.php @@ -0,0 +1,601 @@ + false, 'error' => 'Invalid URL.', 'body' => '', 'url' => '']; + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 4, + CURLOPT_TIMEOUT => 25, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_USERAGENT => facebookEventImporterUserAgent(), + CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'], + ]); + $body = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $finalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + if (PHP_VERSION_ID < 80000) curl_close($ch); + if ($errno) return ['ok' => false, 'error' => $error ?: 'Request failed.', 'body' => '', 'url' => $url]; + if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP '.$status.' from Facebook.', 'body' => (string)$body, 'url' => $finalUrl ?: $url]; + return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $finalUrl ?: $url]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 25, + 'ignore_errors' => true, + 'header' => "User-Agent: ".facebookEventImporterUserAgent()."\r\nAccept: text/html\r\n", + ], + ]); + $body = @file_get_contents($url, false, $context); + $statusLine = $http_response_header[0] ?? ''; + if ($body === false || ($statusLine && !preg_match('/\s[23]\d\d\s/', $statusLine))) { + return ['ok' => false, 'error' => $statusLine ?: 'Request failed.', 'body' => (string)$body, 'url' => $url]; + } + return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $url]; +} + +function facebookEventDom(string $html): ?DOMDocument { + if (!class_exists('DOMDocument')) return null; + $dom = new DOMDocument(); + $old = libxml_use_internal_errors(true); + $loaded = $dom->loadHTML(''.$html); + libxml_clear_errors(); + libxml_use_internal_errors($old); + return $loaded ? $dom : null; +} + +function facebookEventMeta(string $html): array { + $dom = facebookEventDom($html); + if (!$dom) return []; + $out = []; + foreach ($dom->getElementsByTagName('meta') as $meta) { + $key = $meta->getAttribute('property') ?: $meta->getAttribute('name'); + $content = $meta->getAttribute('content'); + if ($key !== '' && $content !== '') $out[strtolower($key)] = $content; + } + foreach ($dom->getElementsByTagName('link') as $link) { + if (strtolower($link->getAttribute('rel')) === 'canonical' && $link->getAttribute('href') !== '') { + $out['canonical'] = $link->getAttribute('href'); + } + } + return $out; +} + +function facebookEventFindJsonLdEvent(mixed $node): ?array { + if (!is_array($node)) return null; + $type = $node['@type'] ?? ''; + $types = is_array($type) ? $type : [$type]; + foreach ($types as $candidateType) { + if (strtolower((string)$candidateType) === 'event') return $node; + } + foreach ($node as $child) { + $found = facebookEventFindJsonLdEvent($child); + if ($found) return $found; + } + return null; +} + +function facebookEventJsonLd(string $html): ?array { + $dom = facebookEventDom($html); + if (!$dom) return null; + foreach ($dom->getElementsByTagName('script') as $script) { + $type = strtolower($script->getAttribute('type')); + if ($type !== 'application/ld+json') continue; + $json = trim($script->textContent ?? ''); + if ($json === '') continue; + $decoded = json_decode($json, true); + $found = facebookEventFindJsonLdEvent($decoded); + if ($found) return $found; + } + return null; +} + +function facebookEventImageFromValue(mixed $value): string { + if (is_string($value)) return $value; + if (is_array($value)) { + if (isset($value['url'])) return facebookEventImageFromValue($value['url']); + foreach ($value as $item) { + $image = facebookEventImageFromValue($item); + if ($image !== '') return $image; + } + } + return ''; +} + +function facebookEventAddressFromLocation(mixed $location): array { + $venue = ''; + $address = ''; + if (is_string($location)) return [$location, '']; + if (!is_array($location)) return ['', '']; + + $venue = facebookEventText($location['name'] ?? ''); + $addr = $location['address'] ?? ''; + if (is_string($addr)) { + $address = facebookEventText($addr); + } elseif (is_array($addr)) { + $parts = []; + foreach (['streetAddress', 'addressLocality', 'addressRegion', 'postalCode'] as $key) { + if (!empty($addr[$key])) $parts[] = facebookEventText($addr[$key]); + } + $address = implode(', ', array_filter($parts)); + } + return [$venue, $address]; +} + +function facebookEventSplitDateTime(string $value): array { + $value = facebookEventText(str_replace([' at ', ' AT '], ' ', $value)); + if ($value === '') return ['', '']; + $hasTime = (bool)preg_match('/(?:T|\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*(?:am|pm)\b)/i', $value); + try { + $dt = new DateTimeImmutable($value); + } catch (Throwable) { + return ['', '']; + } + return [$dt->format('Y-m-d'), $hasTime ? $dt->format('g:i A') : '']; +} + +function facebookEventDateFromText(string $text): array { + $months = 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?'; + if (preg_match('/\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)?(?:day)?[,]?\s*(?:'.$months.')\s+\d{1,2}(?:,\s*\d{4})?(?:\s+(?:at|@)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?/i', $text, $match)) { + return facebookEventSplitDateTime($match[0]); + } + if (preg_match('/\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{1,2}:\d{2}(?::\d{2})?)?/i', $text, $match)) { + return facebookEventSplitDateTime($match[0]); + } + return ['', '']; +} + +function facebookEventCleanTitle(string $title): string { + $title = facebookEventText($title); + $title = preg_replace('/\s*\|\s*Facebook\s*$/i', '', $title); + $title = preg_replace('/\s*-\s*Facebook\s*$/i', '', $title); + return trim($title); +} + +function facebookEventWebsiteForStorage(string $url): string { + $url = trim($url); + if ($url === '') return ''; + $url = preg_replace('~^https?://~i', '', $url); + return preg_replace('~/+$~', '', $url); +} + +function facebookEventExtractEventUrls(string $html, string $sourceUrl = 'https://www.facebook.com'): array { + $links = []; + $dom = facebookEventDom($html); + if ($dom) { + foreach ($dom->getElementsByTagName('a') as $a) { + $href = $a->getAttribute('href'); + $url = facebookEventNormalizeEventUrl($href, $sourceUrl); + if ($url !== '') $links[$url] = $url; + } + } + + $decoded = html_entity_decode($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $decoded = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $decoded); + if (preg_match_all('~https?://(?:www\.)?facebook\.com/(?:events|share)/[^\s"\'<>\\\\]+~i', $decoded, $matches)) { + foreach ($matches[0] as $raw) { + $url = facebookEventNormalizeEventUrl($raw, $sourceUrl); + if ($url !== '') $links[$url] = $url; + } + } + if (preg_match_all('~href=["\']([^"\']*(?:/events/|/share/)[^"\']*)["\']~i', $decoded, $matches)) { + foreach ($matches[1] as $raw) { + $url = facebookEventNormalizeEventUrl($raw, $sourceUrl); + if ($url !== '') $links[$url] = $url; + } + } + return array_values($links); +} + +function facebookEventParseHtml(string $html, string $sourceUrl = '', string $area = ''): array { + $jsonLd = facebookEventJsonLd($html); + $meta = facebookEventMeta($html); + $sourceUrl = facebookEventNormalizeEventUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? '')) ?: facebookEventNormalizeUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? '')); + + $candidate = [ + 'provider' => 'facebook', + 'source_area' => $area, + 'source_url' => $sourceUrl, + 'external_id' => facebookEventExternalId($sourceUrl), + 'title' => '', + 'description' => '', + 'venue' => '', + 'address' => '', + 'event_date' => '', + 'event_time' => '', + 'end_date' => null, + 'end_time' => '', + 'cost' => 'Free', + 'website' => facebookEventWebsiteForStorage($sourceUrl), + 'image_url' => '', + 'image_path' => '', + 'raw_json' => ['json_ld' => $jsonLd, 'meta' => $meta], + ]; + + if ($jsonLd) { + $candidate['title'] = facebookEventCleanTitle((string)($jsonLd['name'] ?? '')); + $candidate['description'] = facebookEventText($jsonLd['description'] ?? ''); + [$candidate['event_date'], $candidate['event_time']] = facebookEventSplitDateTime((string)($jsonLd['startDate'] ?? '')); + [$endDate, $endTime] = facebookEventSplitDateTime((string)($jsonLd['endDate'] ?? '')); + $candidate['end_date'] = $endDate ?: null; + $candidate['end_time'] = $endTime; + [$candidate['venue'], $candidate['address']] = facebookEventAddressFromLocation($jsonLd['location'] ?? null); + $candidate['image_url'] = facebookEventNormalizeUrl(facebookEventImageFromValue($jsonLd['image'] ?? ''), $sourceUrl ?: 'https://www.facebook.com'); + if (!empty($jsonLd['url'])) { + $candidate['source_url'] = facebookEventNormalizeEventUrl((string)$jsonLd['url'], $sourceUrl ?: 'https://www.facebook.com') ?: $candidate['source_url']; + $candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']); + $candidate['external_id'] = facebookEventExternalId($candidate['source_url']); + } + if (!empty($jsonLd['offers']['price'])) $candidate['cost'] = facebookEventText($jsonLd['offers']['price']); + } + + if ($candidate['title'] === '') $candidate['title'] = facebookEventCleanTitle($meta['og:title'] ?? $meta['twitter:title'] ?? ''); + if ($candidate['description'] === '') $candidate['description'] = facebookEventText($meta['og:description'] ?? $meta['description'] ?? $meta['twitter:description'] ?? ''); + if ($candidate['image_url'] === '') $candidate['image_url'] = facebookEventNormalizeUrl($meta['og:image'] ?? $meta['twitter:image'] ?? '', $sourceUrl ?: 'https://www.facebook.com'); + if ($candidate['event_date'] === '') { + [$candidate['event_date'], $candidate['event_time']] = facebookEventDateFromText($candidate['title'].' '.$candidate['description']); + } + if ($candidate['source_url'] === '' && !empty($meta['og:url'])) { + $candidate['source_url'] = facebookEventNormalizeUrl($meta['og:url']); + $candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']); + $candidate['external_id'] = facebookEventExternalId($candidate['source_url']); + } + return $candidate; +} + +function facebookGraphEventUrl(string $eventId): string { + $eventId = trim($eventId); + return $eventId !== '' ? 'https://www.facebook.com/events/'.rawurlencode($eventId) : ''; +} + +function facebookGraphPlaceToVenueAddress(mixed $place): array { + if (!is_array($place)) return ['', '']; + $venue = facebookEventText($place['name'] ?? ''); + $location = $place['location'] ?? []; + if (!is_array($location)) return [$venue, '']; + + $parts = []; + foreach (['street', 'city', 'state', 'zip', 'country'] as $key) { + if (!empty($location[$key])) $parts[] = facebookEventText($location[$key]); + } + return [$venue, implode(', ', array_filter($parts))]; +} + +function facebookGraphEventToCandidate(array $event, string $area = ''): array { + $eventId = trim((string)($event['id'] ?? '')); + $sourceUrl = facebookGraphEventUrl($eventId); + [$eventDate, $eventTime] = facebookEventSplitDateTime((string)($event['start_time'] ?? '')); + [$endDate, $endTime] = facebookEventSplitDateTime((string)($event['end_time'] ?? '')); + [$venue, $address] = facebookGraphPlaceToVenueAddress($event['place'] ?? null); + $ticketUrl = facebookEventNormalizeUrl((string)($event['ticket_uri'] ?? '')); + $website = $ticketUrl !== '' ? $ticketUrl : $sourceUrl; + $cover = $event['cover'] ?? []; + $imageUrl = is_array($cover) ? facebookEventNormalizeUrl((string)($cover['source'] ?? '')) : ''; + + return [ + 'provider' => 'facebook', + 'source_area' => $area, + 'source_url' => $sourceUrl, + 'external_id' => $eventId, + 'title' => facebookEventCleanTitle((string)($event['name'] ?? '')), + 'description' => facebookEventText($event['description'] ?? ''), + 'venue' => $venue, + 'address' => $address, + 'event_date' => $eventDate, + 'event_time' => $eventTime, + 'end_date' => $endDate ?: null, + 'end_time' => $endTime, + 'cost' => 'Free', + 'website' => facebookEventWebsiteForStorage($website), + 'image_url' => $imageUrl, + 'image_path' => '', + 'raw_json' => ['graph_event' => $event], + ]; +} + +function facebookEventDownloadImage(string $imageUrl): array { + $imageUrl = facebookEventNormalizeUrl($imageUrl); + if ($imageUrl === '') return ['ok' => true, 'path' => '', 'error' => '']; + if (!facebookEventAllowedImageHost($imageUrl)) { + return ['ok' => false, 'path' => '', 'error' => 'Image host is not a Facebook image host.']; + } + $maxBytes = mbToBytes(setting('event_upload_max_mb', '5'), 5); + $tmp = tempnam(sys_get_temp_dir(), 'fb-event-img-'); + if (!$tmp) return ['ok' => false, 'path' => '', 'error' => 'Could not create a temporary image file.']; + $mime = ''; + $size = 0; + + if (function_exists('curl_init')) { + $fh = fopen($tmp, 'wb'); + if (!$fh) return ['ok' => false, 'path' => '', 'error' => 'Could not write a temporary image file.']; + $ch = curl_init($imageUrl); + curl_setopt_array($ch, [ + CURLOPT_FILE => $fh, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 4, + CURLOPT_TIMEOUT => 25, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_USERAGENT => facebookEventImporterUserAgent(), + CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use ($fh, $maxBytes, &$size): int { + $size += strlen($chunk); + if ($size > $maxBytes) return 0; + $written = fwrite($fh, $chunk); + return $written === false ? 0 : $written; + }, + ]); + $ok = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $mime = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + if (PHP_VERSION_ID < 80000) curl_close($ch); + fclose($fh); + if (!$ok || $errno || $status < 200 || $status >= 400) { + @unlink($tmp); + $message = $size > $maxBytes ? 'Image exceeds the configured size limit.' : ($error ?: 'Image download failed.'); + return ['ok' => false, 'path' => '', 'error' => $message]; + } + } else { + $context = stream_context_create(['http' => ['timeout' => 25, 'ignore_errors' => true, 'header' => 'User-Agent: '.facebookEventImporterUserAgent()."\r\n"]]); + $body = @file_get_contents($imageUrl, false, $context); + if ($body === false || strlen($body) > $maxBytes) { + @unlink($tmp); + return ['ok' => false, 'path' => '', 'error' => $body === false ? 'Image download failed.' : 'Image exceeds the configured size limit.']; + } + file_put_contents($tmp, $body); + } + + if (!function_exists('getimagesize') || !@getimagesize($tmp)) { + @unlink($tmp); + return ['ok' => false, 'path' => '', 'error' => 'Downloaded file is not a valid image.']; + } + if ($mime === '' && function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo) { + $mime = (string)finfo_file($finfo, $tmp); + finfo_close($finfo); + } + } + $ext = match (strtolower(strtok($mime, ';') ?: '')) { + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + default => 'jpg', + }; + $stored = storeUploadedFile( + ['name' => 'facebook-event.'.$ext, 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmp) ?: 0], + 'events', + imageExts(), + $maxBytes, + [ + 'image_only' => true, + 'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb', '1'), 1), + 'image_max_width' => (int)setting('forum_image_max_width', '1600'), + 'image_quality' => (int)setting('forum_image_quality', '82'), + ] + ); + if (!$stored['ok']) @unlink($tmp); + return $stored['ok'] + ? ['ok' => true, 'path' => (string)$stored['path'], 'error' => ''] + : ['ok' => false, 'path' => '', 'error' => $stored['error'] ?? 'Image could not be saved.']; +} + +function facebookEventCandidateIssues(array $candidate): array { + $issues = []; + if (trim((string)($candidate['title'] ?? '')) === '') $issues[] = 'Missing title'; + $date = trim((string)($candidate['event_date'] ?? '')); + $dt = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + if ($date === '' || !$dt || $dt->format('Y-m-d') !== $date) $issues[] = 'Missing valid start date'; + return $issues; +} + +function facebookEventDuplicateId(array $candidate): int { + $title = trim((string)($candidate['title'] ?? '')); + $date = trim((string)($candidate['event_date'] ?? '')); + if ($title === '' || $date === '') return 0; + return (int)qval("SELECT id FROM events WHERE lower(title)=lower(?) AND event_date=? LIMIT 1", [$title, $date]); +} + +function facebookEventStageCandidate(array $candidate, bool $downloadImage = true): array { + $provider = 'facebook'; + $sourceUrl = facebookEventNormalizeEventUrl((string)($candidate['source_url'] ?? '')) ?: facebookEventNormalizeUrl((string)($candidate['source_url'] ?? '')); + $candidate['source_url'] = $sourceUrl; + $candidate['external_id'] = trim((string)($candidate['external_id'] ?? facebookEventExternalId($sourceUrl))); + $candidate['website'] = facebookEventWebsiteForStorage((string)($candidate['website'] ?? $sourceUrl)); + $candidate['cost'] = trim((string)($candidate['cost'] ?? '')) ?: 'Free'; + $candidate['end_date'] = ($candidate['end_date'] ?? '') ?: null; + $candidate['raw_json'] = $candidate['raw_json'] ?? []; + + if ($downloadImage && empty($candidate['image_path']) && !empty($candidate['image_url'])) { + $image = facebookEventDownloadImage((string)$candidate['image_url']); + if ($image['ok'] && !empty($image['path'])) { + $candidate['image_path'] = $image['path']; + } elseif (!$image['ok']) { + $candidate['status_note'] = 'Image not downloaded: '.$image['error']; + } + } + + $existing = null; + if ($sourceUrl !== '') $existing = qone("SELECT * FROM event_imports WHERE provider=? AND source_url=? LIMIT 1", [$provider, $sourceUrl]); + if (!$existing && $candidate['external_id'] !== '') { + $existing = qone("SELECT * FROM event_imports WHERE provider=? AND external_id=? LIMIT 1", [$provider, $candidate['external_id']]); + } + + $fields = [ + 'provider' => $provider, + 'source_area' => facebookEventText($candidate['source_area'] ?? ''), + 'source_url' => $sourceUrl, + 'external_id' => $candidate['external_id'], + 'title' => facebookEventCleanTitle((string)($candidate['title'] ?? '')), + 'description' => facebookEventText($candidate['description'] ?? ''), + 'venue' => facebookEventText($candidate['venue'] ?? ''), + 'address' => facebookEventText($candidate['address'] ?? ''), + 'event_date' => trim((string)($candidate['event_date'] ?? '')), + 'event_time' => facebookEventText($candidate['event_time'] ?? ''), + 'end_date' => $candidate['end_date'], + 'end_time' => facebookEventText($candidate['end_time'] ?? ''), + 'cost' => facebookEventText($candidate['cost']), + 'website' => facebookEventWebsiteForStorage((string)$candidate['website']), + 'image_url' => facebookEventNormalizeUrl((string)($candidate['image_url'] ?? ''), $sourceUrl ?: 'https://www.facebook.com'), + 'image_path' => trim((string)($candidate['image_path'] ?? '')), + 'raw_json' => json_encode($candidate['raw_json'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}', + 'status_note' => facebookEventText($candidate['status_note'] ?? ''), + ]; + + if ($existing) { + if (($existing['status'] ?? '') === 'imported') { + return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'already_imported']; + } + qrun( + "UPDATE event_imports SET source_area=?,source_url=?,external_id=?,title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_url=?,image_path=?,raw_json=?,status='staged',status_note=?,updated_at=datetime('now') WHERE id=?", + [$fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note'], (int)$existing['id']] + ); + return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'updated']; + } + + $id = qrun( + "INSERT INTO event_imports(provider,source_area,source_url,external_id,title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,image_url,image_path,raw_json,status,status_note)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'staged',?)", + [$fields['provider'], $fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note']] + ); + return ['ok' => true, 'id' => $id, 'action' => 'created']; +} + +function facebookEventSaveCandidateFields(int $id, array $row): void { + qrun( + "UPDATE event_imports SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_path=?,status_note='',updated_at=datetime('now') WHERE id=? AND status!='imported'", + [ + facebookEventCleanTitle((string)($row['title'] ?? '')), + facebookEventText($row['description'] ?? ''), + facebookEventText($row['venue'] ?? ''), + facebookEventText($row['address'] ?? ''), + trim((string)($row['event_date'] ?? '')), + facebookEventText($row['event_time'] ?? ''), + trim((string)($row['end_date'] ?? '')) ?: null, + facebookEventText($row['end_time'] ?? ''), + facebookEventText($row['cost'] ?? 'Free') ?: 'Free', + facebookEventWebsiteForStorage((string)($row['website'] ?? '')), + trim((string)($row['image_path'] ?? '')), + $id, + ] + ); +} + +function facebookEventImportCandidate(int $id, int $userId, string $eventStatus = 'pending', bool $allowDuplicate = false): array { + $candidate = qone("SELECT * FROM event_imports WHERE id=?", [$id]); + if (!$candidate) return ['ok' => false, 'error' => 'Import candidate not found.']; + if (($candidate['status'] ?? '') === 'imported') return ['ok' => false, 'error' => 'This candidate has already been imported.']; + + $issues = facebookEventCandidateIssues($candidate); + if ($issues) return ['ok' => false, 'error' => implode('; ', $issues).'.']; + + $duplicateId = facebookEventDuplicateId($candidate); + if ($duplicateId && !$allowDuplicate) return ['ok' => false, 'error' => 'Possible duplicate of event #'.$duplicateId.'.']; + + $eventStatus = in_array($eventStatus, ['pending', 'approved'], true) ? $eventStatus : 'pending'; + $imagePath = (string)($candidate['image_path'] ?? ''); + $note = ''; + if ($imagePath === '' && !empty($candidate['image_url'])) { + $image = facebookEventDownloadImage((string)$candidate['image_url']); + if ($image['ok'] && !empty($image['path'])) { + $imagePath = (string)$image['path']; + } elseif (!$image['ok']) { + $note = 'Imported without image: '.$image['error']; + } + } + + $eventId = qrun( + "INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,submitted_by,status,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)", + [ + $candidate['title'], + $candidate['description'], + $candidate['venue'], + $candidate['address'], + $candidate['event_date'], + $candidate['event_time'], + $candidate['end_date'] ?: null, + $candidate['end_time'], + $candidate['cost'] ?: 'Free', + facebookEventWebsiteForStorage((string)($candidate['website'] ?: $candidate['source_url'])), + '', + '', + '', + $imagePath, + $userId ?: null, + $eventStatus, + ] + ); + qrun("UPDATE event_imports SET status='imported',imported_event_id=?,image_path=?,status_note=?,updated_at=datetime('now') WHERE id=?", [$eventId, $imagePath, $note, $id]); + return ['ok' => true, 'event_id' => $eventId, 'warning' => $note]; +} diff --git a/includes/functions.php b/includes/functions.php index a618e8e..a5f3e41 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -40,7 +40,7 @@ function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; } function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); } function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); } function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); } -function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; } +function isPost(): bool { return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST'; } /* ── CSRF ─────────────────────────────────────────────── */ function csrf(): string { @@ -325,6 +325,17 @@ function forumTopicUrl(array $topic): string { return '/forum/'.rawurlencode((string)$topic['slug']).'/'; } +function eventUrl(array $event): string { + return '/event.php?id='.(int)($event['id'] ?? 0); +} + +function externalHref(string $url): string { + $url = trim($url); + if ($url === '') return ''; + if (preg_match('~^https?://~i', $url)) return $url; + return 'https://'.$url; +} + /* ── Email verification / Mailgun ─────────────────────── */ function requestBaseUrl(): string { $configured = trim(setting('site_base_url', '')); diff --git a/index.php b/index.php index d24f911..16980a9 100644 --- a/index.php +++ b/index.php @@ -99,7 +99,7 @@ include __DIR__.'/includes/header.php'; -
+ diff --git a/scripts/facebook-events-import.php b/scripts/facebook-events-import.php new file mode 100644 index 0000000..104d4a9 --- /dev/null +++ b/scripts/facebook-events-import.php @@ -0,0 +1,329 @@ +#!/usr/bin/env php + 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); +}