├── .gitignore ├── FetLife.php ├── README.markdown └── tests ├── FetLifeUserTest.php ├── bootstrap.php └── phpunit.xml.sample /.gitignore: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | *~ 7 | fl_sessions 8 | tests/phpunit.xml 9 | -------------------------------------------------------------------------------- /FetLife.php: -------------------------------------------------------------------------------- 1 | . 19 | * 20 | * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 21 | * @link http://maymay.net/ 22 | */ 23 | 24 | // Uncomment for minimal debugging. 25 | //ini_set('log_errors', true); 26 | //ini_set('error_log', '/tmp/php_errors.log'); 27 | 28 | if (!defined('FL_SESSIONS_DIR')) { 29 | define('FL_SESSIONS_DIR', dirname(__FILE__) . '/fl_sessions'); 30 | } 31 | 32 | /** 33 | * Base class. 34 | */ 35 | class FetLife { 36 | const base_url = 'https://fetlife.com'; // No trailing slash! 37 | } 38 | 39 | /** 40 | * Handles network connections, logins, logouts, etc. 41 | */ 42 | class FetLifeConnection extends FetLife { 43 | public $usr; // Associated FetLifeUser object. 44 | public $cookiejar; // File path to cookies for this user's connection. 45 | public $csrf_token; // The current CSRF authenticity token to use for doing HTTP POSTs. 46 | public $cur_page; // Source code of the last page retrieved. 47 | public $proxy_url; // The url of the proxy to use. 48 | public $proxy_type; // The type of the proxy to use. 49 | 50 | function __construct ($usr) { 51 | $this->usr = $usr; 52 | // Initialize cookiejar (session store), etc. 53 | $dir = FL_SESSIONS_DIR; 54 | if (!file_exists($dir)) { 55 | if (!mkdir($dir, 0700)) { 56 | die("Failed to create FetLife Sessions store directory at $dir"); 57 | } 58 | } else { 59 | $this->cookiejar = "$dir/{$this->usr->nickname}"; 60 | } 61 | $this->cookiejar = "$dir/{$this->usr->nickname}"; 62 | } 63 | 64 | private function scrapeProxyURL () { 65 | $ch = curl_init( 66 | 'http://www.xroxy.com/proxylist.php?port=&type=Anonymous&ssl=ssl&country=&latency=&reliability=5000' 67 | ); 68 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 69 | $html = curl_exec($ch); 70 | curl_close($ch); 71 | 72 | $dom = new DOMDocument(); 73 | @$dom->loadHTML($html); 74 | $rows = $dom->getElementsByTagName('tr'); 75 | $urls = array(); 76 | foreach ($rows as $row) { 77 | if (0 === strpos($row->getAttribute('class'), 'row')) { 78 | $str = $row->getElementsByTagName('a')->item(0)->getAttribute('href'); 79 | parse_str($str); 80 | $urls[] = array('host' => $host, 'port' => $port); 81 | } 82 | } 83 | $n = mt_rand(0, count($urls) - 1); // choose a random proxy from the scraped list 84 | $p = parse_url("https://{$urls[$n]['host']}:{$urls[$n]['port']}"); 85 | return array( 86 | 'url' => "{$p['host']}:{$p['port']}", 87 | 'type' => ('socks' === $p['scheme']) ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP 88 | ); 89 | } 90 | 91 | // A flag to pass to curl_setopt()'s proxy settings. 92 | public function setProxy ($url, $type = CURLPROXY_HTTP) { 93 | if ('auto' === $url) { 94 | $p = $this->scrapeProxyURL(); 95 | $url = $p['url']; 96 | $type = $p['type']; 97 | } 98 | $this->proxy_url = $url; 99 | $this->proxy_type = $type; 100 | } 101 | 102 | /** 103 | * Log in to FetLife. 104 | * 105 | * @param object $usr A FetLifeUser to log in as. 106 | * @return bool True if successful, false otherwise. 107 | */ 108 | public function logIn () { 109 | // Grab FetLife login page HTML to get CSRF token. 110 | $ch = curl_init(self::base_url . '/users/sign_in'); 111 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 112 | curl_setopt($ch, CURLOPT_COOKIEFILE, $this->cookiejar); // cookies are set when viewing sign_in page 113 | curl_setopt($ch, CURLOPT_COOKIEJAR, $this->cookiejar); // and required for successful login 114 | if ($this->proxy_url) { 115 | curl_setopt($ch, CURLOPT_PROXY, $this->proxy_url); 116 | curl_setopt($ch, CURLOPT_PROXYTYPE, $this->proxy_type); 117 | } 118 | $this->setCsrfToken($this->findCsrfToken(curl_exec($ch))); 119 | curl_close($ch); 120 | 121 | // Set up login credentials. 122 | $post_data = http_build_query(array( 123 | 'user[login]' => $this->usr->nickname, 124 | 'user[password]' => $this->usr->password, 125 | 'user[otp_attempt]' => 'step_1', 126 | 'authenticity_token' => $this->csrf_token, 127 | 'utf8' => '✓' 128 | )); 129 | 130 | // Log in to FetLife. 131 | return $this->doHttpPost('/users/sign_in', $post_data); 132 | } 133 | 134 | /** 135 | * Calls doHttpRequest with the POST option set. 136 | */ 137 | public function doHttpPost ($url_path, $data = '') { 138 | return $this->doHttpRequest($url_path, $data, 'POST'); 139 | } 140 | 141 | /** 142 | * Calls doHttpRequest with the GET option set. 143 | */ 144 | public function doHttpGet ($url_path, $data = '') { 145 | return $this->doHttpRequest($url_path, $data); // 'GET' is the default. 146 | } 147 | 148 | /** 149 | * Generic HTTP request function. 150 | * 151 | * @param string $url_path The request URI to send to FetLife. E.g., "/users/1". 152 | * @param string $data Parameters to send in the HTTP request. Recommended to use http_build_query(). 153 | * @param string $method The HTTP method to use, like GET (default), POST, etc. 154 | * @return array $r The result of the HTTP request. 155 | */ 156 | private function doHttpRequest ($url_path, $data, $method = 'GET') { 157 | //var_dump($this->csrf_token); 158 | if (!empty($data) && 'GET' === $method) { 159 | $url_path += "?$data"; 160 | } 161 | $ch = curl_init(self::base_url . $url_path); 162 | if ('POST' === $method) { 163 | curl_setopt($ch, CURLOPT_POST, true); 164 | curl_setopt($ch, CURLOPT_POSTFIELDS, $data); 165 | } 166 | curl_setopt($ch, CURLOPT_COOKIEFILE, $this->cookiejar); // use session cookies 167 | curl_setopt($ch, CURLOPT_COOKIEJAR, $this->cookiejar); // save session cookies 168 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); 169 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 170 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 171 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 172 | curl_setopt($ch, CURLINFO_HEADER_OUT, true); 173 | if ($this->proxy_url) { 174 | curl_setopt($ch, CURLOPT_PROXY, $this->proxy_url); 175 | curl_setopt($ch, CURLOPT_PROXYTYPE, $this->proxy_type); 176 | } 177 | 178 | $r = array(); 179 | $this->cur_page = $r['body'] = curl_exec($ch); // Grab FetLife response body. 180 | $this->setCsrfToken($this->findCsrfToken($r['body'])); // Update on each request. 181 | $r['curl_info'] = curl_getinfo($ch); 182 | curl_close($ch); 183 | 184 | $this->cur_page = htmlentities($this->cur_page, ENT_COMPAT, 'UTF-8'); // for debugging - no need to keep it encoded AFAIK 185 | return $r; 186 | } 187 | 188 | /** 189 | * Given some HTML from FetLife, this finds the current user ID. 190 | * 191 | * @param string $str Some raw HTML expected to be from FetLife.com. 192 | * @return mixed User ID on success. False on failure. 193 | */ 194 | public function findUserId ($str) { 195 | $m = array(); 196 | preg_match('/FetLife\.currentUser\.id\s*=\s*([0-9]+);/', $str, $m); 197 | if (empty($m[1])) { // Sometimes FetLife returns different HTML. 198 | preg_match('/var currentUserId\s*=\s*([0-9]+)/', $str, $m); 199 | } 200 | return (empty($m[1])) ? false : $m[1]; 201 | } 202 | 203 | /** 204 | * Given some HTML from FetLife, this finds a user's nickname. 205 | * 206 | * @param string $str Some raw HTML expected to be from FetLife.com. 207 | * @return mixed User nickname on Success. False on failure. 208 | */ 209 | public function findUserNickname ($str) { 210 | $matches = array(); 211 | preg_match('/([-_A-Za-z0-9]+) - Kinksters - FetLife<\/title>/', $str, $matches); 212 | return $matches[1]; 213 | } 214 | 215 | /** 216 | * Given some HTML from FetLife, this finds the current CSRF Token. 217 | * 218 | * @param string $str Some raw HTML expected to be form FetLife.com. 219 | * @return mixed CSRF Token string on success. False on failure. 220 | */ 221 | private function findCsrfToken ($str) { 222 | $matches = array(); 223 | preg_match('/<meta name="csrf-token" content="([^"]+)"/', $str, $matches); 224 | // Decode numeric HTML entities if there are any. See also: 225 | // http://www.php.net/manual/en/function.html-entity-decode.php#104617 226 | $r = preg_replace_callback( 227 | '/(&#[0-9]+;)/', 228 | create_function( 229 | '$m', 230 | 'return mb_convert_encoding($m[1], \'UTF-8\', \'HTML-ENTITIES\');' 231 | ), 232 | $matches[1] 233 | ); 234 | return $r; 235 | } 236 | 237 | private function setCsrfToken ($csrf_token) { 238 | $this->csrf_token = $csrf_token; 239 | } 240 | } 241 | 242 | /** 243 | * A FetLife User. This class mimics the logged-in user, performing actions, etc. 244 | */ 245 | class FetLifeUser extends FetLife { 246 | public $nickname; 247 | public $password; 248 | public $id; 249 | public $email_address; 250 | public $connection; // A FetLifeConnection object to handle network requests. 251 | public $friends; // An array (eventually, of FetLifeProfile objects). 252 | 253 | function __construct ($nickname, $password) { 254 | $this->nickname = $nickname; 255 | $this->password = $password; 256 | $this->connection = new FetLifeConnection($this); 257 | } 258 | 259 | /** 260 | * Logs in to FetLife as the given user. 261 | * 262 | * @return bool True if login was successful, false otherwise. 263 | */ 264 | function logIn () { 265 | $response = $this->connection->logIn(); 266 | if ($this->id = $this->connection->findUserId($response['body'])) { 267 | return true; 268 | } else { 269 | return false; 270 | } 271 | } 272 | 273 | /** 274 | * Translates a FetLife user's nickname to their numeric ID. 275 | */ 276 | function getUserIdByNickname ($nickname = NULL) { 277 | if (!$nickname) { 278 | $nickname = $this->nickname; 279 | } 280 | 281 | if ($nickname === $this->nickname && !empty($this->id)) { 282 | return $this->id; 283 | } else { 284 | $result = $this->connection->doHttpGet("/$nickname"); 285 | $url_parts = parse_url($result['curl_info']['url']); 286 | return current(array_reverse(explode('/', $url_parts['path']))); 287 | } 288 | } 289 | 290 | /** 291 | * Translates a FetLife user's ID to their nickname. 292 | */ 293 | function getUserNicknameById ($id = NULL) { 294 | if (isset($this->id) && !$id) { 295 | $id = $this->id; 296 | } 297 | 298 | $result = $this->connection->doHttpGet("/users/$id"); 299 | return $this->connection->findUserNickname($result['body']); 300 | } 301 | 302 | /** 303 | * Queries FetLife for a user's profile information. 304 | * 305 | * @param mixed $who Nickname or user ID of a FetLife profile. 306 | * @return mixed A FetLifeProfile object on success, FALSE on failure. 307 | */ 308 | function getUserProfile ($who = NULL) { 309 | $id = $this->resolveWho($who); 310 | $profile = new FetLifeProfile(array( 311 | 'usr' => $this, 312 | 'id' => $id 313 | )); 314 | try { 315 | $profile->populate(); 316 | return $profile; 317 | } catch (Exception $e) { 318 | return false; 319 | } 320 | } 321 | 322 | /** 323 | * Retrieves a user's friend list. 324 | * 325 | * @param mixed $who User whose friends list to search. If a string, treats it as a FetLife nickname and resolves to a numeric ID. If an integer, uses that ID. By default, the logged-in user. 326 | * @param int $pages How many pages to retrieve. By default, retrieves all (0). 327 | * @return array $friends Array of FetLifeProfile objects. 328 | */ 329 | function getFriendsOf ($who = NULL, $pages = 0) { 330 | $id = $this->resolveWho($who); 331 | return $this->getUsersInListing("/users/$id/friends", $pages); 332 | } 333 | 334 | /** 335 | * Helper function to resolve "$who" we're dealing with. 336 | * 337 | * @param mixed $who The entity to resolve. If a string, assumes a nickname and resolves to an ID. If an integer, uses that. 338 | * @return int The FetLife user's numeric ID. 339 | */ 340 | private function resolveWho ($who) { 341 | switch (gettype($who)) { 342 | case 'NULL': 343 | return $this->id; 344 | case 'integer': 345 | return $who; 346 | case 'string': 347 | // Double-check that an integer wasn't passed a string. 348 | if (ctype_digit($who)) { 349 | return (int)$who; // If it was, coerce type appropriately. 350 | } else { 351 | return $this->getUserIdByNickname($who); 352 | } 353 | } 354 | } 355 | 356 | /** 357 | * Helper function to determine whether we've been bounced to the "Home" page. 358 | * This might happen if the Profile page we're trying to load doesn't exist. 359 | * 360 | * TODO: Is there a more elegant way for handling this kind of "error"? 361 | */ 362 | function isHomePage ($str) { 363 | return (preg_match('/<title>Home - FetLife<\/title>/', $str)) ? true: false; 364 | } 365 | 366 | /** 367 | * Helper function to determine whether we've gotten back an HTTP error page. 368 | * 369 | * TODO: Is there a more elegant way for handling this kind of "error"? 370 | */ 371 | function isHttp500ErrorPage ($str) { 372 | return (preg_match('/<p class="error_code">500 Internal Server Error<\/p>/', $str)) ? true: false; 373 | } 374 | 375 | /** 376 | * Retrieves a user's Writings. 377 | * 378 | * @param mixed $who User whose FetLife Writings to fetch. If a string, treats it as a FetLife nickname and resolves to a numeric ID. If an integer, uses that ID. By default, the logged-in user. 379 | * @param int $pages How many pages to retrieve. By default, retrieves all (0). 380 | * @return array $writings Array of FetLifeWritings objects. 381 | */ 382 | public function getWritingsOf ($who = NULL, $pages = 0) { 383 | $id = $this->resolveWho($who); 384 | $items = $this->getItemsInListing('//article', "/users/$id/posts", $pages); 385 | $ret = array(); 386 | foreach ($items as $v) { 387 | $x = array(); 388 | $x['title'] = $v->getElementsByTagName('h2')->item(0)->nodeValue; 389 | $x['category'] = trim($v->getElementsByTagName('strong')->item(0)->nodeValue); 390 | $author_url = $v->getElementsByTagName('a')->item(0)->attributes->getNamedItem('href')->value; 391 | $author_id = (int) current(array_reverse(explode('/', $author_url))); 392 | $author_avatar = $v->getElementsByTagName('img')->item(0)->attributes->getNamedItem('src')->value; 393 | $x['creator'] = new FetLifeProfile(array( 394 | 'id' => $author_id, 395 | 'avatar_url' => $author_avatar 396 | )); 397 | $x['url'] = $v->getElementsByTagName('a')->item(1)->attributes->getNamedItem('href')->value; 398 | $x['id'] = (int) current(array_reverse(explode('/', $x['url']))); 399 | $x['dt_published'] = $v->getElementsByTagName('time')->item(0)->attributes->getNamedItem('datetime')->value; 400 | $x['content'] = $v->getElementsByTagName('div')->item(1); // save the DOMElement object 401 | $x['usr'] = $this; 402 | $ret[] = new FetLifeWriting($x); 403 | } 404 | return $ret; 405 | } 406 | 407 | /** 408 | * Retrieves a user's single Writing. 409 | * 410 | * @param mixed $id ID of a FetLife Writing. 411 | * @param mixed $who User whose FetLife Writings to fetch. If a string, treats it as a FetLife nickname and resolves to a numeric ID. If an integer, uses that ID. By default, the logged-in user. 412 | * @return mixed $writing A FetLifeWriting object, false if not found. 413 | */ 414 | public function getWritingOf ($id, $who = NULL) { 415 | $author_id = $this->resolveWho($who); 416 | 417 | $x = array(); 418 | $x['creator'] = $this->getUserProfile($author_id); 419 | $x['id'] = $id; 420 | $x['usr'] = $this; 421 | $ret = new FetLifeWriting($x); 422 | $ret->populate(); 423 | 424 | return $ret; 425 | } 426 | 427 | /** 428 | * Retrieves a user's Pictures. 429 | */ 430 | public function getPicturesOf ($who = NULL, $pages = 0) { 431 | $id = $this->resolveWho($who); 432 | $items = $this->getItemsInListing('//ul[contains(@class, "page")]/li', "/users/$id/pictures", $pages); 433 | $ret = array(); 434 | foreach ($items as $v) { 435 | $x = array(); 436 | $x['url'] = $v->getElementsByTagName('a')->item(0)->attributes->getNamedItem('href')->value; 437 | $x['id'] = (int) current(array_reverse(explode('/', $x['url']))); 438 | $x['thumb_src'] = $v->getElementsByTagName('img')->item(0)->attributes->getNamedItem('src')->value; 439 | $x['src'] = preg_replace('/_110\.(jpg|jpeg|gif|png)$/', '_720.$1', $x['thumb_src']); // This is a good guess. 440 | $x['content'] = $v->getElementsByTagName('img')->item(0)->attributes->getNamedItem('alt')->value; 441 | $x['creator'] = new FetLifeProfile(array('id' => $id)); 442 | $x['usr'] = $this; 443 | $ret[] = new FetLifePicture($x); 444 | } 445 | return $ret; 446 | } 447 | 448 | /** 449 | * Retrieves list of group members. 450 | * 451 | * @param int $group_id The ID of the group. 452 | * @param int $pages How many pages to retrieve. By default, retrieve all (0). 453 | * @return array $members Array of DOMElement objects from the group's "user_in_list" elements. 454 | */ 455 | public function getMembersOfGroup ($group_id, $pages = 0) { 456 | return $this->getUsersInListing("/groups/$group_id/group_memberships", $pages); 457 | } 458 | 459 | public function getKinkstersWithFetish ($fetish_id, $pages = 0) { 460 | return $this->getUsersInListing("/fetishes/$fetish_id/kinksters", $pages); 461 | } 462 | public function getKinkstersGoingToEvent ($event_id, $pages = 0) { 463 | return $this->getUsersInListing("/events/$event_id/rsvps", $pages); 464 | } 465 | public function getKinkstersMaybeGoingToEvent ($event_id, $pages = 0) { 466 | return $this->getUsersInListing("/events/$event_id/rsvps/maybe", $pages); 467 | } 468 | public function getKinkstersInLocation ($loc_str, $pages = 0) { 469 | return $this->getUsersInListing("/administrative_areas/$loc_str/kinksters", $pages); 470 | } 471 | 472 | public function searchKinksters ($query, $pages = 0) { 473 | return $this->getUsersInListing('/search/kinksters', $pages, "q=$query"); 474 | } 475 | 476 | /** 477 | * Performs a quick search of "everything" on FetLife, but only returns the first page of results. 478 | * To get more information, do a specific search by object type. 479 | * 480 | * @param string $query The search query. 481 | * @return object $results Search results by type. 482 | */ 483 | public function search ($query) { 484 | $results = new stdClass(); 485 | $results->kinksters = $this->getUsersInListing('/search', $pages = 1, "q=$query"); 486 | return $results; 487 | } 488 | 489 | /** 490 | * Gets a single event. 491 | * 492 | * @param int $id The event ID to fetch. 493 | * @param mixed $populate True to populate all data, integer to retrieve that number of RSVP pages, false (default) to do nothing. 494 | */ 495 | function getEventById ($id, $populate = false) { 496 | $event = new FetLifeEvent(array( 497 | 'usr' => $this, 498 | 'id' => $id, 499 | )); 500 | $event->populate($populate); 501 | return $event; 502 | } 503 | 504 | /** 505 | * Retrieves list of events. 506 | * 507 | * TODO: Create an automated way of translating place names to place URL strings. 508 | * @param string $loc_str The "Place" URL part. For instance, "cities/5898" is "Baltimore, Maryland, United States". 509 | * @param int $pages How many pages to retrieve. By default, retrieve all (0). 510 | */ 511 | function getUpcomingEventsInLocation ($loc_str, $pages = 0) { 512 | return $this->getEventsInListing("/$loc_str/events", $pages); 513 | } 514 | 515 | /** 516 | * Loads a specific page from a paginated list. 517 | * 518 | * @param string $url The URL of the paginated set. 519 | * @param int $page The number of the page in the set. 520 | * @param string $qs A query string to append to the URL. 521 | * @return array The result of the HTTP request. 522 | * @see FetLifeConnection::doHttpRequest 523 | */ 524 | // TODO: This should really be a bit more sensible. 525 | private function loadPage ($url, $page = 1, $qs = '') { 526 | if ($page > 1) { 527 | $url .= "?page=$page&"; 528 | } else if (!empty($qs)) { 529 | $url .= '?'; 530 | } 531 | if (!empty($qs)) { 532 | $url .= $qs; 533 | } 534 | $res = $this->connection->doHttpGet($url); 535 | return $res; 536 | } 537 | 538 | /** 539 | * Counts number of pages in a paginated listing. 540 | * 541 | * @param DOMDocument $doc The page to look for paginated numbering in. 542 | * @return int Number of pages. 543 | */ 544 | private function countPaginatedPages ($doc) { 545 | $result = $this->doXPathQuery('//a[@class="next_page"]/../a', $doc); // get all pagination elements 546 | if (0 === $result->length) { 547 | // This is the first (and last) page. 548 | $num_pages = 1; 549 | } else { 550 | $num_pages = (int) $result->item($result->length - 2)->textContent; 551 | } 552 | return $num_pages; 553 | } 554 | 555 | // Helper function to return the results of an XPath query. 556 | public function doXPathQuery ($x, $doc) { 557 | $xpath = new DOMXPath($doc); 558 | return $xpath->query($x); 559 | } 560 | 561 | /** 562 | * Iterates through a listing of users, such as a friends list or group membership list. 563 | * 564 | * @param string $url_base The base URL for the listing pages. 565 | * @param int $pages The number of pages to iterate through. 566 | * @param string $qs A query string to append to the URL. 567 | * @return array Array of FetLifeProfile objects from the listing's "user_in_list" elements. 568 | */ 569 | private function getUsersInListing ($url_base, $pages, $qs = '') { 570 | $items = $this->getItemsInListing('//*[contains(@class, "user_in_list")]', $url_base, $pages, $qs); 571 | $ret = array(); 572 | foreach ($items as $v) { 573 | $u = array(); 574 | $u['nickname'] = $v->getElementsByTagName('img')->item(0)->attributes->getNamedItem('alt')->value; 575 | $u['avatar_url'] = $v->getElementsByTagName('img')->item(0)->attributes->getNamedItem('src')->value; 576 | $u['url'] = $v->getElementsByTagName('a')->item(0)->attributes->getNamedItem('href')->value; 577 | $u['id'] = current(array_reverse(explode('/', $u['url']))); 578 | list(, $u['age'], $u['gender'], $u['role']) = $this->parseAgeGenderRole($v->getElementsByTagName('span')->item(1)->nodeValue); 579 | $u['location'] = trim($v->getElementsByTagName('em')->item(0)->nodeValue); 580 | $pieces = array_map('trim', explode(',', $u['location'])); 581 | $u['adr']['locality'] = $pieces[0]; 582 | $u['adr']['region'] = $pieces[1]; 583 | $ret[] = new FetLifeProfile($u); 584 | } 585 | return $ret; 586 | } 587 | 588 | private function parseItemsInListing ($xpath, $doc) { 589 | $items = array(); 590 | $entries = $this->doXPathQuery($xpath, $doc); 591 | foreach ($entries as $entry) { 592 | $items[] = $entry; 593 | } 594 | return $items; 595 | } 596 | 597 | // TODO: Perhaps these utility functions ought go in their own parser class? 598 | /** 599 | * Helper function to parse some info from a FetLife "profile_header" block. 600 | * 601 | * @param DOMDocument $doc The DOMDocument representing the page we're parsing. 602 | * @return FetLifeProfile A FetLifeProfile object. 603 | */ 604 | public function parseProfileHeader ($doc) { 605 | $hdr = $doc->getElementById('profile_header'); 606 | $el = $hdr->getElementsByTagName('img')->item(0); 607 | $author_name = $el->attributes->getNamedItem('alt')->value; 608 | $author_avatar = $el->attributes->getNamedItem('src')->value; 609 | $author_url = $hdr->getElementsByTagName('a')->item(0)->attributes->getNamedItem('href')->value; 610 | $author_id = (int) current(array_reverse(explode('/', $author_url))); 611 | list(, $author_age, 612 | $author_gender, 613 | $author_role) = $this->parseAgeGenderRole($this->doXPathQuery('//*[@class="age_gender_role"]', $doc)->item(0)->nodeValue); 614 | // substr() is used to remove the parenthesis around the location here. 615 | $author_location = substr($this->doXPathQuery('//*[@class="location"]', $doc)->item(0)->nodeValue, 1, -1); 616 | return new FetLifeProfile(array( 617 | 'nickname' => $author_name, 618 | 'avatar_url' => $author_avatar, 619 | 'id' => $author_id, 620 | 'age' => $author_age, 621 | 'gender' => $author_gender, 622 | 'role' => $author_role, 623 | 'location' => $author_location 624 | )); 625 | } 626 | function parseAgeGenderRole ($str) { 627 | $m = array(); 628 | preg_match('/^([0-9]{2})(\S+)? (\S+)?$/', $str, $m); 629 | return $m; 630 | } 631 | function parseIdFromUrl ($url) { 632 | $m = array(); 633 | preg_match('/(\d+)$/', $url, $m); 634 | return ($m[1]) ? $m[1] : false; 635 | } 636 | /** 637 | * Helper function to parse any comments section on the page. 638 | * 639 | * @param DOMDocument $doc The DOMDocument representing the page we're parsing. 640 | * @return Array An Array of FetLifeComment objects. 641 | */ 642 | function parseComments ($doc) { 643 | $ret = array(); 644 | $comments = $doc->getElementById('comments')->getElementsByTagName('article'); 645 | foreach ($comments as $comment) { 646 | $commenter_el = $comment->getElementsByTagName('a')->item(0); 647 | $commenter_url = $commenter_el->attributes->getNamedItem('href')->value; 648 | $ret[] = new FetLifeComment(array( 649 | 'id' => (int) current(array_reverse(explode('_', $comment->getAttribute('id')))), 650 | 'creator' => new FetLifeProfile(array( 651 | 'url' => $commenter_url, 652 | 'id' => (int) current(array_reverse(explode('/', $commenter_url))), 653 | 'avatar_url' => $commenter_el->getElementsByTagName('img')->item(0)->attributes->getNamedItem('src')->value, 654 | 'nickname' => $commenter_el->getElementsByTagName('img')->item(0)->attributes->getNamedItem('alt')->value 655 | )), 656 | 'dt_published' => $comment->getElementsByTagName('time')->item(0)->attributes->getNamedItem('datetime')->value, 657 | 'content' => $comment->getElementsByTagName('div')->item(0) 658 | )); 659 | } 660 | return $ret; 661 | } 662 | /** 663 | * Helper function to parse out info from a profile's associated content lists. 664 | * 665 | * @param DOMDocument $doc The DOMDocument representing the page we're parsing. 666 | * @param string $type The specific section of associated content to parse. 667 | * @param mixed $relation_to_user A string for group "leader", group "member", event "organizing", event "maybe_going". Default bool false. 668 | * @return Object An object with two members, item_ids and items, each arrays. 669 | */ 670 | public function parseAssociatedContentInfo ($doc, $obj_type, $relation_to_user = false) { 671 | $ret = new stdClass(); 672 | $ret->items = array(); 673 | $ret->item_ids = array(); 674 | 675 | switch ($obj_type) { 676 | case 'event': 677 | switch ($relation_to_user) { 678 | case 'organizing': 679 | $str = 'Events organizing'; 680 | break; 681 | case 'maybe_going': 682 | $str = 'Events maybe going to'; 683 | break; 684 | case 'going': 685 | default: 686 | $str = 'Events going to'; 687 | break; 688 | } 689 | break; 690 | case 'writing': 691 | $str = 'Writing'; 692 | break; 693 | case 'group': 694 | switch ($relation_to_user) { 695 | case 'leader': 696 | $str = 'Groups I lead'; 697 | break; 698 | case 'member': 699 | default: 700 | $str = 'Groups member of'; 701 | break; 702 | } 703 | break; 704 | } 705 | $obj = 'FetLife' . ucfirst($obj_type); 706 | 707 | $els = $this->doXPathQuery("//h4[starts-with(normalize-space(.), '$str')]/following-sibling::ul[1]", $doc); 708 | if ($els->length) { 709 | foreach ($els->item(0)->getElementsByTagName('a') as $el) { 710 | // explode() to extract the group number from like: href="/groups/1234" 711 | $id = $this->parseIdFromUrl($el->getAttribute('href')); 712 | if ($relation_to_user) { 713 | $ret->item_ids[] = $id; 714 | } 715 | $ret->items[] = new $obj(array( 716 | 'usr' => $this, 717 | 'id' => $id, 718 | 'name' => $el->firstChild->textContent 719 | )); 720 | } 721 | } 722 | 723 | return $ret; 724 | } 725 | 726 | /** 727 | * Iterates through a set of events from a given multi-page listing. 728 | * 729 | * @param string $url_base The base URL for the listing pages. 730 | * @param int $pages The number of pages to iterate through. 731 | * @return array Array of FetLifeEvent objects from the listed set. 732 | */ 733 | private function getEventsInListing ($url_base, $pages) { 734 | $items = $this->getItemsInListing('//*[contains(@class, "event_listings")]/li', $url_base, $pages); 735 | $ret = array(); 736 | foreach ($items as $v) { 737 | $e = array(); 738 | $e['title'] = $v->getElementsByTagName('a')->item(0)->nodeValue; 739 | $e['url'] = $v->getElementsByTagName('a')->item(0)->attributes->getNamedItem('href')->value; 740 | $e['id'] = current(array_reverse(explode('/', $e['url']))); 741 | // Suppress this warning because we're manually appending UTC timezone marker. 742 | $start_timestamp = @strtotime($v->getElementsByTagName('div')->item(1)->nodeValue . ' UTC'); 743 | $e['dtstart'] = ($start_timestamp) ? 744 | gmstrftime('%Y-%m-%d %H:%MZ', $start_timestamp) : $v->getElementsByTagName('div')->item(1)->nodeValue; 745 | $e['venue_name'] = $v->getElementsByTagName('div')->item(2)->nodeValue; 746 | $e['usr'] = $this; 747 | $ret[] = new FetLifeEvent($e); 748 | } 749 | 750 | return $ret; 751 | } 752 | 753 | /** 754 | * Iterates through a multi-page listing of items that match an XPath query. 755 | * 756 | * @param string $xpath An XPath string for scraping the desired HTML elements. 757 | * @param string $url_base The base URL of the possibly-paginated page to scrape. 758 | * @param int $pages The number of pages to iterate through. 759 | * @param string $qs A query string to append to the base URL. 760 | */ 761 | private function getItemsInListing ($xpath, $url_base, $pages, $qs = '') { 762 | // Retrieve the first page. 763 | $cur_page = 1; 764 | $x = $this->loadPage($url_base, $cur_page, $qs); 765 | 766 | $doc = new DOMDocument(); 767 | @$doc->loadHTML($x['body']); 768 | 769 | $num_pages = $this->countPaginatedPages($doc); 770 | // If retrieving all pages, set the page retrieval limit to the last existing page. 771 | if (0 === $pages) { 772 | $pages = $num_pages; 773 | } 774 | 775 | // Find and store items on this page. 776 | $items = $this->parseItemsInListing($xpath, $doc); 777 | 778 | // Find and store items on remainder of pages. 779 | while ( ($cur_page < $num_pages) && ($cur_page < $pages) ) { 780 | $cur_page++; // increment to get to next page 781 | $x = $this->loadPage($url_base, $cur_page, $qs); 782 | @$doc->loadHTML($x['body']); 783 | $items = array_merge($items, $this->parseItemsInListing($xpath, $doc)); 784 | } 785 | 786 | return $items; 787 | } 788 | } 789 | 790 | /** 791 | * Base class for various content items within FetLife. 792 | */ 793 | abstract class FetLifeContent extends FetLife { 794 | public $usr; //< Associated FetLifeUser object. 795 | public $id; 796 | public $content; //< DOMElement object. Use `getContentHtml()` to get as string. 797 | public $dt_published; 798 | public $creator; //< A FetLifeProfile who created the content 799 | 800 | function __construct ($arr_param) { 801 | // TODO: Rewrite this a bit more defensively. 802 | foreach ($arr_param as $k => $v) { 803 | $this->$k = $v; 804 | } 805 | } 806 | 807 | function __sleep () { 808 | if (isset($this->content) && !empty($this->content)) { 809 | $this->content = $this->getContentHtml(true); 810 | } 811 | return array_keys(get_object_vars($this)); 812 | } 813 | 814 | function __wakeup () { 815 | $html = $this->content; 816 | $nodes = array(); 817 | $doc = new DOMDocument(); 818 | @$doc->loadHTML("<html>{$html}</html>"); 819 | $child = $doc->documentElement->firstChild; 820 | while($child) { 821 | $nodes[] = $doc->importNode($child,true); 822 | $child = $child->nextSibling; 823 | } 824 | $this->content = reset($nodes); 825 | } 826 | 827 | abstract public function getUrl (); 828 | 829 | // Return the full URL, with fragment identifier. 830 | // TODO: Should this become an abstract class to enforce this contract? 831 | // If so, what should be done with the class variables? They'll 832 | // get changed to be class constants, which may not be acceptable. 833 | public function getPermalink () { 834 | return self::base_url . $this->getUrl(); 835 | } 836 | 837 | // Fetches and fills in the remainder of the object's data. 838 | // For this to work, child classes must define their own parseHtml() method. 839 | public function populate () { 840 | $resp = $this->usr->connection->doHttpGet($this->getUrl()); 841 | $data = $this->parseHtml($resp['body']); 842 | foreach ($data as $k => $v) { 843 | $this->$k = $v; 844 | } 845 | } 846 | 847 | public function getContentHtml ($pristine = true) { 848 | $html = ''; 849 | if (!empty($this->content) && $this->content instanceof DOMElement) { 850 | $doc = new DOMDocument(); 851 | foreach ($this->content->childNodes as $node) { 852 | $el = $doc->importNode($node, true); 853 | $html .= $doc->saveHTML($el); 854 | } 855 | } else { 856 | $html = $this->content; 857 | } 858 | $html = $pristine ? $html : htmlentities($html, ENT_COMPAT, 'UTF-8'); 859 | return $html; 860 | } 861 | } 862 | 863 | /** 864 | * A FetLife Writing published by a user. 865 | */ 866 | class FetLifeWriting extends FetLifeContent { 867 | public $title; 868 | public $category; 869 | public $privacy; 870 | public $comments; // An array of FetLifeComment objects. 871 | // TODO: Implement "love" fetching? 872 | public $loves; 873 | 874 | // Returns the server-relative URL of the profile. 875 | public function getUrl () { 876 | return '/users/' . $this->creator->id . '/posts/' . $this->id; 877 | } 878 | 879 | // Given some HTML of a FetLife writing page, returns an array of its data. 880 | function parseHtml ($html) { 881 | $doc = new DOMDocument(); 882 | @$doc->loadHTML($html); 883 | $ret = array(); 884 | 885 | $ret['creator'] = $this->usr->parseProfileHeader($doc); 886 | 887 | $ret['title'] = $doc->getElementsByTagName('h2')->item(0)->nodeValue; 888 | $ret['content'] = $this->usr->doXPathQuery('//*[@id="post_content"]//div', $doc)->item(1); 889 | $ret['category'] = trim($this->usr->doXPathQuery('//*[@id="post_content"]//header//strong', $doc)->item(0)->nodeValue); 890 | $ret['dt_published'] = $this->usr->doXPathQuery('//*[@id="post_content"]//time/@datetime', $doc)->item(0)->value; 891 | $ret['privacy'] = $this->usr->doXPathQuery('//*[@id="privacy_section"]//*[@class="display"]', $doc)->item(0)->nodeValue; 892 | 893 | $ret['comments'] = $this->usr->parseComments($doc); 894 | return $ret; 895 | } 896 | 897 | // Override parent's implementation to strip out final paragraph from 898 | // contents that were scraped from a Writing listing page. 899 | function getContentHtml ($pristine = true) { 900 | $html = ''; 901 | if (!empty($this->content) && $this->content instanceof DOMElement) { 902 | $doc = new DOMDocument(); 903 | foreach ($this->content->childNodes as $node) { 904 | $el = $doc->importNode($node, true); 905 | // Strip out FetLife's own "Read NUMBER comments" paragraph 906 | if ($el->hasAttributes() && (false !== stripos($el->attributes->getNamedItem('class')->value, 'no_underline')) ) { 907 | continue; 908 | } 909 | $html .= $doc->saveHTML($el); 910 | } 911 | } else { 912 | $html = $this->content; 913 | } 914 | 915 | $html = $pristine ? $html : htmlentities($html, ENT_COMPAT, 'UTF-8'); 916 | return $html; 917 | } 918 | } 919 | 920 | /** 921 | * A FetLife Picture page. (Not the <img/> itself.) 922 | */ 923 | class FetLifePicture extends FetLifeContent { 924 | public $src; // The fully-qualified URL of the image itself. 925 | public $thumb_src; // The fully-qualified URL of the thumbnail. 926 | public $comments; 927 | 928 | public function getUrl () { 929 | return "/users/{$this->creator->id}/pictures/{$this->id}"; 930 | } 931 | 932 | // Parses a FetLife Picture page's HTML. 933 | function parseHtml ($html) { 934 | $doc = new DOMDocument(); 935 | @$doc->loadHTML($html); 936 | $ret = array(); 937 | 938 | $ret['creator'] = $this->usr->parseProfileHeader($doc); 939 | 940 | // TODO: I guess I could look at the actual page instea of guessing? 941 | //$ret['src']; 942 | $ret['content'] = $this->usr->doXPathQuery('//span[contains(@class, "caption")]', $doc)->item(0); 943 | $ret['dt_published'] = $doc->getElementById('picture')->getElementsByTagName('time')->item(0)->attributes->getNamedItem('datetime')->value; 944 | 945 | $ret['comments'] = $this->usr->parseComments($doc); 946 | 947 | return $ret; 948 | } 949 | 950 | } 951 | 952 | /** 953 | * Generic class for comments on FetLife contents. 954 | */ 955 | class FetLifeComment extends FetLifeContent { 956 | public $creator; 957 | 958 | public function getUrl () { 959 | return parent::getUrl() . '#' . $this->getContentType() . "_comment_{$this->id}"; 960 | } 961 | 962 | // Helper function to reflect on what this comment is attached to. 963 | private function getContentType () { 964 | switch ($x = get_parent_class($this)) { 965 | case 'FetLifeWriting': 966 | return 'post'; 967 | case 'FetLifeStatus': 968 | return 'status'; 969 | default: 970 | return $x; 971 | } 972 | } 973 | } 974 | 975 | /** 976 | * Profile information for a FetLife User. 977 | */ 978 | class FetLifeProfile extends FetLifeContent { 979 | public $age; 980 | public $avatar_url; 981 | public $gender; 982 | public $location; // TODO: Split this up? 983 | public $adr; 984 | public $nickname; 985 | public $role; 986 | public $relationships; // TODO 987 | public $orientation; 988 | public $paying_account; 989 | public $num_friends; //< Number of friends displayed on their profile. 990 | public $bio; //< Whatever's in the "About Me" section 991 | public $websites; //< An array of URLs listed by the profile. 992 | public $fetishes; //< An array of FetLifeFetish objects, eventually 993 | 994 | protected $events; //< Array of FetLifeEvent objects 995 | protected $groups; //< Array of FetLifeGroup objects 996 | protected $groups_lead; //< Array of group IDs for which this profile is a group leader. 997 | protected $groups_member; //< Array of group IDs for which this profile is a group member. 998 | protected $events_going; //< Array of event IDs for which this user RSVP'ed "going" 999 | protected $events_maybe_going; //< Array of event IDs for which this user RSVP'ed "maybe going" 1000 | protected $events_organizing; //< Array of event IDs this user is organizing 1001 | 1002 | 1003 | function __construct ($arr_param) { 1004 | parent::__construct($arr_param); 1005 | unset($this->creator); // Profile can't have a creator; it IS a creator. 1006 | } 1007 | 1008 | // Returns the server-relative URL of the profile. 1009 | public function getUrl () { 1010 | return '/users/' . $this->id; 1011 | } 1012 | 1013 | /** 1014 | * Returns the fully-qualified URL of the profile. 1015 | * 1016 | * @param bool $named If true, returns the canonical URL by nickname. 1017 | */ 1018 | public function getPermalink ($named = false) { 1019 | if ($named) { 1020 | return self::base_url . "/{$this->nickname}"; 1021 | } else { 1022 | return self::base_url . $this->getUrl(); 1023 | } 1024 | } 1025 | 1026 | public function getAvatarURL ($size = 60) { 1027 | return $this->transformAvatarURL($this->avatar_url, $size); 1028 | } 1029 | 1030 | public function getEvents () { 1031 | return $this->events; 1032 | } 1033 | public function getGroups () { 1034 | return $this->groups; 1035 | } 1036 | 1037 | /** 1038 | * Tiny helper to reduce some code duplication. 1039 | * @see parseAssociatedContentInfo 1040 | */ 1041 | // TODO: Normalize the semantics between this function and 1042 | // the paramters for parseAssociatedContentInfo. 1043 | private function getSegmentOf ($obj_type, $segment) { 1044 | $r = array(); 1045 | $item_list = "{$obj_type}_{$segment}"; 1046 | foreach ($this->$item_list as $id) { 1047 | foreach ($this->$obj_type as $x) { 1048 | if ($id == $x->id) { 1049 | $r[] = $x; 1050 | } 1051 | } 1052 | } 1053 | return $r; 1054 | } 1055 | public function getEventsGoingTo () { 1056 | return $this->getSegmentOf('events', 'going'); 1057 | } 1058 | public function getEventsMaybeGoingTo () { 1059 | return $this->getSegmentOf('events', 'maybe_going'); 1060 | } 1061 | public function getEventsOrganizing () { 1062 | return $this->getSegmentOf('events', 'organizing'); 1063 | } 1064 | public function getGroupsLead () { 1065 | return $this->getSegmentOf('groups', 'lead'); 1066 | } 1067 | 1068 | // Given some HTML of a FetLife Profile page, returns an array of its data. 1069 | function parseHtml ($html) { 1070 | // Don't try parsing if we got bounced off the Profile for any reason. 1071 | if ($this->usr->isHomePage($html) || $this->usr->isHttp500ErrorPage($html)) { 1072 | throw new Exception('FetLife Profile does not exist.'); 1073 | } 1074 | $doc = new DOMDocument(); 1075 | @$doc->loadHTML($html); 1076 | $ret = array(); 1077 | 1078 | // TODO: Defensively check for HTML elements successfully scraped, this is sloppy. 1079 | if ($el = $doc->getElementsByTagName('h2')->item(0)) { 1080 | list(, $ret['age'], $ret['gender'], $ret['role']) = $this->usr->parseAgeGenderRole($el->getElementsByTagName('span')->item(0)->nodeValue); 1081 | } 1082 | if ($el = $this->usr->doXPathQuery('//*[@class="pan"]', $doc)->item(0)) { 1083 | $ret['avatar_url'] = $el->attributes->getNamedItem('src')->value; 1084 | } 1085 | if ($el = $doc->getElementsByTagName('em')->item(0)) { 1086 | $ret['location'] = $el->nodeValue; 1087 | $els = $el->getElementsByTagName('a'); 1088 | if (3 === $els->length) { 1089 | $ret['adr']['locality'] = $els->item(0)->nodeValue; 1090 | $ret['adr']['region'] = $els->item(1)->nodeValue; 1091 | $ret['adr']['country-name'] = $els->item(2)->nodeValue; 1092 | } else if (2 === $els->length) { 1093 | $ret['adr']['region'] = $els->item(0)->nodeValue; 1094 | $ret['adr']['country-name'] = $els->item(1)->nodeValue; 1095 | } else if (1 === $els->length) { 1096 | $ret['adr']['country-name'] = $els->item(0)->nodeValue; 1097 | } 1098 | } 1099 | if ($el = $doc->getElementsByTagName('img')->item(0)) { 1100 | $ret['nickname'] = $el->attributes->getNamedItem('alt')->value; 1101 | } 1102 | if ($el = $this->usr->doXPathQuery('//*[contains(@class, "donation_badge")]', $doc)->item(0)) { 1103 | $ret['paying_account'] = $el->nodeValue; 1104 | } 1105 | if ($el = $doc->getElementsByTagName('h4')->item(0)) { 1106 | if ($el_x = $el->getElementsByTagName('span')->item(0)) { 1107 | $ret['num_friends'] = (int) str_replace(',', '', substr($el_x->nodeValue, 1, -1)); // Strip enclosing parenthesis and commas for results like "(1,057)" 1108 | } else { 1109 | $ret['num_friends'] = 0; 1110 | } 1111 | } 1112 | 1113 | // Parse out event info 1114 | $x = $this->usr->parseAssociatedContentInfo($doc, 'event', 'going'); 1115 | $ret['events_going'] = $x->item_ids; 1116 | $ret['events'] = $x->items; 1117 | $x = $this->usr->parseAssociatedContentInfo($doc, 'event', 'maybe_going'); 1118 | $ret['events_maybe_going'] = $x->item_ids; 1119 | $ret['events'] = array_merge($ret['events'], $x->items); 1120 | $x = $this->usr->parseAssociatedContentInfo($doc, 'event', 'organizing'); 1121 | $ret['events_organizing'] = $x->item_ids; 1122 | $ret['events'] = array_merge($ret['events'], $x->items); 1123 | 1124 | 1125 | // Parse out group info 1126 | $x = $this->usr->parseAssociatedContentInfo($doc, 'group', 'leader'); 1127 | $ret['groups_lead'] = $x->item_ids; 1128 | $ret['groups'] = $x->items; 1129 | $x = $this->usr->parseAssociatedContentInfo($doc, 'group', 'member'); 1130 | $ret['groups_member'] = $x->item_ids; 1131 | $ret['groups'] = array_merge($ret['groups'], $x->items); 1132 | 1133 | return $ret; 1134 | } 1135 | 1136 | /** 1137 | * Whether or not this user profile has a paid subscription to FetLife. 1138 | */ 1139 | function isPayingAccount () { 1140 | return ($this->paying_account) ? true : false; 1141 | } 1142 | 1143 | /** 1144 | * Will use regex replace to transform the resolution of the avatar_url's 1145 | * found. i.e. From 60px to 200px. Does not guarantee Fetlife will have the 1146 | * requested resolution however. 1147 | */ 1148 | function transformAvatarURL ($avatar_url, $size) { 1149 | return preg_replace('/_[0-9]+\.jpg$/', "_$size.jpg", $avatar_url); 1150 | } 1151 | } 1152 | 1153 | /** 1154 | * A Status object. 1155 | */ 1156 | class FetLifeStatus extends FetLifeContent { 1157 | const MAX_STATUS_LENGTH = 200; // Character count. 1158 | public $text; 1159 | public $url; 1160 | 1161 | // TODO 1162 | function __construct ($str) { 1163 | $this->text = $str; 1164 | } 1165 | 1166 | // TODO 1167 | public function getUrl () { 1168 | } 1169 | } 1170 | 1171 | /** 1172 | * An Event object. 1173 | */ 1174 | class FetLifeEvent extends FetLifeContent { 1175 | // See event creation form at https://fetlife.com/events/new 1176 | public $title; 1177 | public $tagline; 1178 | public $dtstart; 1179 | public $dtend; 1180 | public $venue_name; // Text of the venue name, if provided. 1181 | public $venue_address; // Text of the venue address, if provided. 1182 | public $adr = array(); // Array of elements matching adr microformat. 1183 | public $cost; 1184 | public $dress_code; 1185 | public $description; 1186 | public $going; // An array of FetLifeProfile objects who are RSVP'ed "Yes." 1187 | public $maybegoing; // An array of FetLifeProfile objects who are RSVP'ed "Maybe." 1188 | 1189 | // Returns the server-relative URL of the event. 1190 | public function getUrl () { 1191 | return '/events/' . $this->id; 1192 | } 1193 | 1194 | public function getParticipants () { 1195 | $r = array($this->creator); 1196 | return array_merge($r, $this->going, $this->maybegoing); 1197 | } 1198 | 1199 | /** 1200 | * Fetches and fills the remainder of the Event's data. 1201 | * 1202 | * This is public because it'll take a long time and so it is recommended to 1203 | * do so only when you need specific data. 1204 | * 1205 | * @param mixed $rsvp_pages Number of RSVP pages to get, if any. Default is false, which means attendee lists won't be fetched. Passing true means "all". 1206 | */ 1207 | public function populate ($rsvp_pages = false) { 1208 | $resp = $this->usr->connection->doHttpGet($this->getUrl()); 1209 | $data = $this->parseEventHtml($resp['body']); 1210 | foreach ($data as $k => $v) { 1211 | $this->$k = $v; 1212 | } 1213 | if ($rsvp_pages) { 1214 | $rsvp_pages = (true === $rsvp_pages) ? 0 : $rsvp_pages; // Privately, 0 means "all". 1215 | $this->going = $this->usr->getKinkstersGoingToEvent($this->id, $rsvp_pages); 1216 | $this->maybegoing = $this->usr->getKinkstersMaybeGoingToEvent($this->id, $rsvp_pages); 1217 | } 1218 | } 1219 | 1220 | // Given some HTML of a FetLife event page, returns an array of its data. 1221 | private function parseEventHtml ($html) { 1222 | $doc = new DOMDocument(); 1223 | @$doc->loadHTML($html); 1224 | $ret = array(); 1225 | $ret['title'] = $this->usr->doXPathQuery('//h1[@itemprop="name"]', $doc)->item(0)->textContent; 1226 | if(!empty($this->usr->doXPathQuery('//h1[contains(@itemprop, "name")]/following-sibling::p', $doc)->item(0))) { 1227 | $ret['tagline'] = $this->usr->doXPathQuery('//h1[contains(@itemprop, "name")]/following-sibling::p', $doc)->item(0)->nodeValue; 1228 | } 1229 | $ret['dtstart'] = $this->usr->doXPathQuery('//*[contains(@itemprop, "startDate")]/@content', $doc)->item(0)->nodeValue; 1230 | $ret['dtend'] = $this->usr->doXPathQuery('//*[contains(@itemprop, "endDate")]/@content', $doc)->item(0)->nodeValue; 1231 | if(!empty($this->usr->doXPathQuery('//*[contains(@itemprop, "name")]', $doc)->item(1))) { 1232 | $ret['venue_name'] = $this->usr->doXPathQuery('//*[contains(@itemprop, "name")]', $doc)->item(1)->nodeValue; 1233 | } 1234 | if(!empty($this->usr->doXPathQuery('//th/*[text()="Location:"]/../../td/*[contains(@class, "s")]/text()[1]', $doc)->item(0))) { 1235 | $ret['venue_address'] = $this->usr->doXPathQuery('//th/*[text()="Location:"]/../../td/*[contains(@class, "s")]/text()[1]', $doc)->item(0)->nodeValue; 1236 | } 1237 | if ($location = $this->usr->doXPathQuery('//*[contains(@itemprop, "location")]', $doc)->item(0)) { 1238 | $ret['adr']['country-name'] = $location->getElementsByTagName('meta')->item(0)->attributes->getNamedItem('content')->value; 1239 | $ret['adr']['region'] = $location->getElementsByTagName('meta')->item(1)->attributes->getNamedItem('content')->value; 1240 | if ($locality = $location->getElementsByTagName('meta')->item(2)) { 1241 | $ret['adr']['locality'] = $locality->attributes->getNamedItem('content')->value; 1242 | } 1243 | } 1244 | if (!empty($this->usr->doXPathQuery('//th[text()="Cost:"]/../td', $doc)->item(0))) { 1245 | $ret['cost'] = $this->usr->doXPathQuery('//th[text()="Cost:"]/../td', $doc)->item(0)->nodeValue; 1246 | } 1247 | 1248 | if (!empty($this->usr->doXPathQuery('//th[text()="Dress code:"]/../td', $doc)->item(0))) { 1249 | $ret['dress_code'] = $this->usr->doXPathQuery('//th[text()="Dress code:"]/../td', $doc)->item(0)->textContent; 1250 | } 1251 | // TODO: Save an HTML representation of the description, then make a getter that returns a text-only version. 1252 | // See also http://www.php.net/manual/en/class.domelement.php#101243 1253 | $ret['description'] = $this->usr->doXPathQuery('//*[contains(@class, "description")]', $doc)->item(0)->nodeValue; 1254 | if ($creator_link = $this->usr->doXPathQuery('//h3[text()="Created by"]/following-sibling::ul//a', $doc)->item(0)) { 1255 | $ret['creator'] = $ret['created_by'] = new FetLifeProfile(array( // both for backwards compatibility 1256 | 'url' => $creator_link->attributes->getNamedItem('href')->value, 1257 | 'id' => current(array_reverse(explode('/', $creator_link->attributes->getNamedItem('href')->value))), 1258 | 'avatar_url' => $creator_link->getElementsByTagName('img')->item(0)->attributes->getNamedItem('src')->value, 1259 | 'nickname' => $creator_link->getElementsByTagName('img')->item(0)->attributes->getNamedItem('alt')->value 1260 | )); 1261 | } 1262 | return $ret; 1263 | } 1264 | } 1265 | 1266 | /** 1267 | * A FetLife Group. 1268 | */ 1269 | class FetLifeGroup extends FetLifeContent { 1270 | public $name; //< Group display name, its title, etc. 1271 | public $members; //< An array of FetLifeProfile objects. 1272 | public $about; //< The group's "About & Rules" contents. 1273 | public $num_posts; //< The number of discussions, as reported on the about page. 1274 | public $num_comments; //< The number of comments, as reported on the about page. 1275 | public $discussions; //< An array of FetLifeGroupDiscussion objects. 1276 | public $last_touch; //< Timestamp of the "last comment" line item, to estimate group activity. 1277 | public $started_on; //< Timestamp of the "started on" date, as reported on the about page. 1278 | 1279 | public function getUrl () { 1280 | return '/groups/' . $this->id; 1281 | } 1282 | 1283 | private function parseGroupHtml ($html) { 1284 | // TODO 1285 | } 1286 | } 1287 | 1288 | // TODO 1289 | class FetLifeGroupDiscussion extends FetLifeContent { 1290 | public function getUrl () { 1291 | return parent::getUrl() . '/group_posts/' . $this->id; 1292 | } 1293 | } 1294 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # libFetLife - README 2 | 3 | `libFetLife` is a PHP class implementing a simple API useful for interfacing with the amateur porn and fetish dating website [FetLife.com](https://fetlife.com/). Learn more [about the political motivation for this library](https://web.archive.org/web/20150912020717/https://bandanablog.wordpress.com/2015/04/30/fetlifes-best-customers/). 4 | 5 | ## System requirements 6 | 7 | To run `libFetLife`, you need PHP version 5.3.6 or greater (with [PHP's cURL extension](https://php.net/manual/book.curl.php) installed). 8 | 9 | ## Getting started 10 | 11 | To use `libFetLife`, include it in your project and instantiate a new `FetLifeUser` object: 12 | 13 | ```php 14 | // Load FetLife API. 15 | require_once 'libFetLife/FetLife.php'; 16 | 17 | // Make a FetLifeUser (like a database handle). 18 | $FL = new FetLifeUser('username', 'password'); 19 | ``` 20 | 21 | You can optionally instruct `libFetLife` to use a proxy instead of making direction connections to FetLife.com: 22 | 23 | ```php 24 | $FL->connection->setProxy('example.proxy.com:9050', CURLPROXY_SOCKS5); // Optional. 25 | $FL->connection->setProxy('auto'); // or, set a new randomized proxy automatically. 26 | ``` 27 | 28 | When you're ready, login with the `FetLifeUser::logIn()` method: 29 | 30 | ```php 31 | $FL->logIn(); 32 | ``` 33 | 34 | Now `$FL` represents you on FetLife: 35 | 36 | ```php 37 | // Print some basic information about the account you're using. 38 | print $FL->id; // your user's numeric ID. 39 | print $FL->nickname; // your user's nickname, the name you signed in with 40 | //etc. 41 | ``` 42 | 43 | You use the `FetLifeUser` object's various public methods to send queries to FetLife. Replies depend on the query method: 44 | 45 | ```php 46 | // Query FetLife for information about other users. 47 | print $FL->getUserIdByNickname('JohnBaku'); // prints "1" 48 | print $FL->getUserNicknameById(1254); // prints "maymay" 49 | ``` 50 | 51 | Other FetLife users are represented as FetLifeProfile objects: 52 | 53 | ```php 54 | // Object-oriented access to user info is available as FetLifeProfile objects. 55 | $profile = $FL->getUserProfile(1); // Profile with ID 1 56 | $profile->nickname; // "JohnBaku" 57 | $profile->age; 58 | $profile->gender; 59 | $profile->role; 60 | 61 | // the `adr` member is an array keyed like its eponymous microformat: 62 | $profile->adr['locality']; // "Vancouver" 63 | $profile->adr['region']; // "British Columbia" 64 | $profile->adr['country-name']; // "Canada" 65 | 66 | // Some FetLifeProfile methods: 67 | $profile->getAvatarURL(); // optional $size parameter retrieves larger images 68 | $profile->isPayingAccount(); // true if the profile has a "supporter" badge 69 | $profile->getEvents(); // array of FetLifeEvent objects listed on the profile 70 | $profile->getEventsGoingTo(); // array of FetLifeEvent the user has RSVP'ed "going" to 71 | $profile->getGroups(); // array of FetLifeGroup objects listed on the profile 72 | $profile->getGroupsLead(); // array of FetLifeGroups the user moderates 73 | ``` 74 | 75 | Many methods return arrays of `FetLifeProfile` objects. Since queries are live, they can also be passed an optional page limiter. 76 | 77 | ```php 78 | // Get a user's friends list as an array of FetLifeProfile objects. 79 | $friends = $FL->getFriendsOf('maymay'); 80 | // A numeric FetLife user ID also works. 81 | $friends = $FL->getFriendsOf(1254); 82 | // If there are many pages, you can set a limit. 83 | $friends_partial = $FL->getFriendsOf('maymay', 3); // Only first 3 pages. 84 | 85 | // Numerous other functions also return arrays, with optional page limit. 86 | $members = $FL->getMembersOfGroup(11708); // "Kink On Tap" 87 | $kinksters = $FL->getKinkstersWithFetish(193); // "Corsets" 88 | $local_kinksters = $FL->getKinkstersInLocation('cities/5898'); // all kinksters in Balitmore, MD. 89 | $attendees = $FL->getKinkstersGoingToEvent(149379); 90 | $maybes = $FL->getKinkstersMaybeGoingToEvent(149379, 2); // Only 2 pages. 91 | ``` 92 | 93 | Most data objects, including `FetLifeProfile`, `FetLifeWriting`, and `FetLifePicture` are descended from a common `FetLifeContent` base class. Such descendants have a `populate()` method that fetches supplemental information about the object from FetLife: 94 | 95 | ```php 96 | // You can also fetch arrays of a user's FetLife data as objects this way. 97 | $writings = $FL->getWritingsOf('JohnBaku'); // All of JohnBaku's Writings. 98 | $pictures = $FL->getPicturesOf(1); // All of JohnBaku's Pictures. 99 | 100 | // If you want to fetch comments, you need to populate() the objects. 101 | $writings_and_pictures = array_merge($writings, $pictures); 102 | foreach ($writings_and_pictures as $item) { 103 | $item->comments; // currently, returns an NULL 104 | $item->populate(); 105 | $item->comments; // now, returns an array of FetLifeComment objects. 106 | } 107 | ``` 108 | 109 | FetLife events can be queried much like profiles: 110 | 111 | ```php 112 | // If you already know the event ID, you can just fetch that event. 113 | $event = $FL->getEventById(151424); 114 | // "Populate" behavior works the same way. 115 | $event = $FL->getEventById(151424, true); // Get all availble event data. 116 | 117 | // You can also fetch arrays of events as FetLifeEvent objects. 118 | $events = $FL->getUpcomingEventsInLocation('cities/5898'); // Get all events in Balitmore, MD. 119 | // Or get just the first couple pages. 120 | $events_partial = $FL->getUpcomingEventsInLocation('cities/5898', 2); // Only 2 pages. 121 | 122 | // FetLifeEvent objects are instantiated from minimal data. 123 | // To fill them out, call their populate() method. 124 | $events[0]->populate(); // Flesh out data from first event fetched. 125 | // RSVP lists take a while to fetch, but you can get them, too. 126 | $events[1]->populate(2); // Fetch first 2 pages of RSVP responses. 127 | $events[2]->populate(true); // Or fetch all pages of RSVP responses. 128 | 129 | // Now we have access to some basic event data. 130 | print $events[2]->getPermalink(); 131 | print $events[2]->venue_name; 132 | print $events[2]->dress_code; 133 | // etc... 134 | 135 | // Attendee lists are arrays of FetLifeProfile objects, same as friends lists. 136 | // You can collect a list of all participants 137 | $everyone = $events[2]->getParticipants(); 138 | 139 | // or interact with the separate RSVP lists individually 140 | foreach ($events[2]->going as $profile) { 141 | print $profile->nickname; // FetLife names of people who RSVP'd "Going." 142 | } 143 | $i = 0; 144 | $y = 0; 145 | foreach ($events[2]->maybegoing as $profile) { 146 | if ('Switch' === $profile->role) { $i++; } 147 | if ('M' === $profile->gender) { $y++; } 148 | } 149 | print "There are $i Switches and $y male-identified people maybe going to {$events[2]->title}."; 150 | ``` 151 | 152 | You can also perform basic searches: 153 | 154 | ```php 155 | $kinksters = $FL->searchKinksters('maymay'); // All Kinksters whose username contains the query. 156 | $partial_kinksters = $FL->searchKinksters('maymay', 5) // only first 5 pages of above results. 157 | ``` 158 | 159 | [Patches welcome](https://github.com/fabacab/libFetLife/issues/new). :) 160 | 161 | ## Testing 162 | 163 | `libFetLife` uses [PHPUnit](https://phpunit.de/) for unit testing. The `tests/` directory includes a `phpunit.xml.sample` file with a default configuration. To run live tests, you need to edit this file so that the global variables `fetlife_username`, `fetlife_password,` and `fetlife_proxyurl` have the values you want to use to create the test runner's `FetLifeUser` object, as described above, and then copy it to `phpunit.xml`. 164 | 165 | cd libFetLife/tests # Inside the tests directory... 166 | vi phpunit.xml.sample # is a sample PHPUnit configuration file. Edit it. 167 | cp phpunit.xml.sample phpunit.xml # Then copy it to PHPUnit's expected location. 168 | 169 | ## Projects that use libFetLife 170 | 171 | * [FetLife WordPress eXtended RSS Generator](https://github.com/fabacab/fetlife2wxr) 172 | * [FetLife iCalendar](https://github.com/fabacab/fetlife-icalendar/) 173 | * [FetLife Maltego](https://github.com/fabacab/fetlife-maltego/) 174 | * [FetLife Export](https://github.com/fabacab/fetlife-export/) 175 | * [FetLife Bridge](https://github.com/fabacab/fetlife-bridge/) 176 | 177 | Are you using `libFetLife`? [Let me know](http://maybemaimed.com/seminars/#booking-inquiry). 178 | -------------------------------------------------------------------------------- /tests/FetLifeUserTest.php: -------------------------------------------------------------------------------- 1 | <?php 2 | class FetLifeUserTest extends PHPUnit_Framework_TestCase { 3 | 4 | protected static $FL; 5 | 6 | // TODO: Use mock/stubs instead of sharing a static $FL and 7 | // making live HTTP requests? 8 | public static function setUpBeforeClass () { 9 | global $fetlife_username, $fetlife_password, $fetlife_proxyurl; 10 | self::$FL = new FetLifeUser($fetlife_username, $fetlife_password); 11 | if ('auto' === $fetlife_proxyurl) { 12 | self::$FL->connection->setProxy('auto'); 13 | } else if ($fetlife_proxyurl) { 14 | $p = parse_url($fetlife_proxyurl); 15 | self::$FL->connection->setProxy( 16 | "{$p['host']}:{$p['port']}", 17 | ('socks' === $p['scheme']) ? CURLPROXY_SOCKS5 : CURLPROXY_HTTP 18 | ); 19 | } 20 | } 21 | 22 | public function testFoundUserId () { 23 | self::$FL->logIn(); 24 | $this->assertNotEmpty(self::$FL->id); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | <?php 2 | /** 3 | * This file is part of the libFetLife Tests package. 4 | */ 5 | require_once dirname(__FILE__) . '/../FetLife.php'; 6 | -------------------------------------------------------------------------------- /tests/phpunit.xml.sample: -------------------------------------------------------------------------------- 1 | <phpunit 2 | bootstrap="bootstrap.php" 3 | convertNoticesToExceptions="false" 4 | > 5 | <php> 6 | <var name="fetlife_username" value="YOUR_USERNAME_HERE" /> 7 | <var name="fetlife_password" value="YOUR_PASSWORD_HERE" /> 8 | <var name="fetlife_proxyurl" value="auto" /> 9 | </php> 10 | </phpunit> 11 | --------------------------------------------------------------------------------