├── .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('/
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}");
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 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 | 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 |
5 |