]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - scripts/twitterstatusfetcher.php
Merge branch '0.8.x' into cmdline
[quix0rs-gnu-social.git] / scripts / twitterstatusfetcher.php
1 #!/usr/bin/env php
2 <?php
3 /**
4  * Laconica - a distributed open-source microblogging tool
5  * Copyright (C) 2008, 2009, Control Yourself, Inc.
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as published by
9  * the Free Software Foundation, either version 3 of the License, or
10  * (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.     If not, see <http://www.gnu.org/licenses/>.
19  */
20
21 // Abort if called from a web server
22 if (isset($_SERVER) && array_key_exists('REQUEST_METHOD', $_SERVER)) {
23     print "This script must be run from the command line\n";
24     exit();
25 }
26
27 define('INSTALLDIR', realpath(dirname(__FILE__) . '/..'));
28 define('LACONICA', true);
29
30 // Tune number of processes and how often to poll Twitter
31 // XXX: Should these things be in config.php?
32 define('MAXCHILDREN', 2);
33 define('POLL_INTERVAL', 60); // in seconds
34
35 // Uncomment this to get useful logging
36 // define('SCRIPT_DEBUG', true);
37
38 // Preset the server at the command line
39
40 $server = ($argc > 2) ? $argv[2] : null;
41 $path   = ($argc > 3) ? $argv[3] : null;
42
43 require_once INSTALLDIR . '/lib/common.php';
44 require_once INSTALLDIR . '/lib/daemon.php';
45
46 /**
47  * Fetcher for statuses from Twitter
48  *
49  * Fetches statuses from Twitter and inserts them as notices in local
50  * system.
51  *
52  * @category Twitter
53  * @package  Laconica
54  * @author   Zach Copley <zach@controlyourself.ca>
55  * @author   Evan Prodromou <evan@controlyourself.ca>
56  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
57  * @link     http://laconi.ca/
58  */
59
60 // NOTE: an Avatar path MUST be set in config.php for this
61 // script to work: e.g.: $config['avatar']['path'] = '/laconica/avatar';
62
63 class TwitterStatusFetcher extends Daemon
64 {
65     private $_children = array();
66
67     /**
68      * Name of this daemon
69      *
70      * @return string Name of the daemon.
71      */
72
73     function name()
74     {
75         return ('twitterstatusfetcher.generic');
76     }
77
78     /**
79      * Run the daemon
80      *
81      * @return void
82      */
83
84     function run()
85     {
86         do {
87
88             $flinks = $this->refreshFlinks();
89
90             foreach ($flinks as $f) {
91
92                 // We have to disconnect from the DB before forking so
93                 // each sub-process will open its own connection and
94                 // avoid stomping on the others
95
96                 $conn = &$f->getDatabaseConnection();
97                 $conn->disconnect();
98
99                 $pid = pcntl_fork();
100
101                 if ($pid == -1) {
102                     die ("Couldn't fork!");
103                 }
104
105                 if ($pid) {
106
107                     // Parent
108                     if (defined('SCRIPT_DEBUG')) {
109                         common_debug("Parent: forked new status ".
110                                      " fetcher process " . $pid);
111                     }
112
113                     $this->_children[] = $pid;
114
115                 } else {
116
117                     // Child
118                     $this->getTimeline($f);
119                     exit();
120                 }
121
122                 // Remove child from ps list as it finishes
123                 while (($c = pcntl_wait($status, WNOHANG OR WUNTRACED)) > 0) {
124
125                     if (defined('SCRIPT_DEBUG')) {
126                         common_debug("Child $c finished.");
127                     }
128
129                     $this->removePs($this->_children, $c);
130                 }
131
132                 // Wait! We have too many damn kids.
133                 if (sizeof($this->_children) > MAXCHILDREN) {
134
135                     if (defined('SCRIPT_DEBUG')) {
136                         common_debug('Too many children. Waiting...');
137                     }
138
139                     if (($c = pcntl_wait($status, WUNTRACED)) > 0) {
140
141                         if (defined('SCRIPT_DEBUG')) {
142                             common_debug("Finished waiting for $c");
143                         }
144
145                         $this->removePs($this->_children, $c);
146                     }
147                 }
148             }
149
150             // Remove all children from the process list before restarting
151             while (($c = pcntl_wait($status, WUNTRACED)) > 0) {
152
153                 if (defined('SCRIPT_DEBUG')) {
154                     common_debug("Child $c finished.");
155                 }
156
157                 $this->removePs($this->_children, $c);
158             }
159
160             // Rest for a bit before we fetch more statuses
161
162             if (defined('SCRIPT_DEBUG')) {
163                 common_debug('Waiting ' . POLL_INTERVAL .
164                     ' secs before hitting Twitter again.');
165             }
166
167             if (POLL_INTERVAL > 0) {
168                 sleep(POLL_INTERVAL);
169             }
170
171         } while (true);
172     }
173
174     /**
175      * Refresh the foreign links for this user
176      *
177      * @return void
178      */
179
180     function refreshFlinks()
181     {
182         $flink = new Foreign_link();
183
184         $flink->service = 1; // Twitter
185
186         $flink->orderBy('last_noticesync');
187
188         $cnt = $flink->find();
189
190         if (defined('SCRIPT_DEBUG')) {
191             common_debug('Updating Twitter friends subscriptions' .
192                 " for $cnt users.");
193         }
194
195         $flinks = array();
196
197         while ($flink->fetch()) {
198
199             if (($flink->noticesync & FOREIGN_NOTICE_RECV) ==
200                 FOREIGN_NOTICE_RECV) {
201                 $flinks[] = clone($flink);
202             }
203         }
204
205         $flink->free();
206         unset($flink);
207
208         return $flinks;
209     }
210
211     /**
212      * Unknown
213      *
214      * @param array  &$plist unknown.
215      * @param string $ps     unknown.
216      *
217      * @return unknown
218      * @todo document
219      */
220
221     function removePs(&$plist, $ps)
222     {
223         for ($i = 0; $i < sizeof($plist); $i++) {
224             if ($plist[$i] == $ps) {
225                 unset($plist[$i]);
226                 $plist = array_values($plist);
227                 break;
228             }
229         }
230     }
231
232     function getTimeline($flink)
233     {
234         if (empty($flink)) {
235             common_log(LOG_WARNING,
236                 "Can't retrieve Foreign_link for foreign ID $fid");
237             return;
238         }
239
240         $fuser = $flink->getForeignUser();
241
242         if (empty($fuser)) {
243             common_log(LOG_WARNING, "Unmatched user for ID " .
244                 $flink->user_id);
245             return;
246         }
247
248         if (defined('SCRIPT_DEBUG')) {
249             common_debug('Trying to get timeline for Twitter user ' .
250                 "$fuser->nickname ($flink->foreign_id).");
251         }
252
253         // XXX: Biggest remaining issue - How do we know at which status
254         // to start importing?  How many statuses?  Right now I'm going
255         // with the default last 20.
256
257         $url = 'http://twitter.com/statuses/friends_timeline.json';
258
259         $timeline_json = get_twitter_data($url, $fuser->nickname,
260             $flink->credentials);
261
262         $timeline = json_decode($timeline_json);
263
264         if (empty($timeline)) {
265             common_log(LOG_WARNING, "Empty timeline.");
266             return;
267         }
268
269         // Reverse to preserve order
270         foreach (array_reverse($timeline) as $status) {
271
272             // Hacktastic: filter out stuff coming from this Laconica
273             $source = mb_strtolower(common_config('integration', 'source'));
274
275             if (preg_match("/$source/", mb_strtolower($status->source))) {
276                 if (defined('SCRIPT_DEBUG')) {
277                     common_debug('Skipping import of status ' . $status->id .
278                         ' with source ' . $source);
279                 }
280                 continue;
281             }
282
283             $this->saveStatus($status, $flink);
284         }
285
286         // Okay, record the time we synced with Twitter for posterity
287         $flink->last_noticesync = common_sql_now();
288         $flink->update();
289     }
290
291     function saveStatus($status, $flink)
292     {
293         $id = $this->ensureProfile($status->user);
294         $profile = Profile::staticGet($id);
295
296         if (!$profile) {
297             common_log(LOG_ERR,
298                 'Problem saving notice. No associated Profile.');
299             return null;
300         }
301
302         // XXX: change of screen name?
303
304         $uri = 'http://twitter.com/' . $status->user->screen_name .
305             '/status/' . $status->id;
306
307         $notice = Notice::staticGet('uri', $uri);
308
309         // check to see if we've already imported the status
310
311         if (!$notice) {
312
313             $notice = new Notice();
314
315             $notice->profile_id = $id;
316             $notice->uri        = $uri;
317             $notice->created    = strftime('%Y-%m-%d %H:%M:%S',
318                                            strtotime($status->created_at));
319             $notice->content    = common_shorten_links($status->text); // XXX
320             $notice->rendered   = common_render_content($notice->content, $notice);
321             $notice->source     = 'twitter';
322             $notice->reply_to   = null; // XXX lookup reply
323             $notice->is_local   = NOTICE_GATEWAY;
324
325             if (Event::handle('StartNoticeSave', array(&$notice))) {
326                 $id = $notice->insert();
327                 Event::handle('EndNoticeSave', array($notice));
328             }
329         }
330
331         if (!Notice_inbox::pkeyGet(array('notice_id' => $notice->id,
332                                          'user_id' => $flink->user_id))) {
333             // Add to inbox
334             $inbox = new Notice_inbox();
335
336             $inbox->user_id   = $flink->user_id;
337             $inbox->notice_id = $notice->id;
338             $inbox->created   = $notice->created;
339             $inbox->source    = NOTICE_INBOX_SOURCE_GATEWAY; // From a private source
340
341             $inbox->insert();
342         }
343     }
344
345     function ensureProfile($user)
346     {
347         // check to see if there's already a profile for this user
348         $profileurl = 'http://twitter.com/' . $user->screen_name;
349         $profile = Profile::staticGet('profileurl', $profileurl);
350
351         if ($profile) {
352             if (defined('SCRIPT_DEBUG')) {
353                 common_debug("Profile for $profile->nickname found.");
354             }
355
356             // Check to see if the user's Avatar has changed
357             $this->checkAvatar($user, $profile);
358
359             return $profile->id;
360
361         } else {
362             if (defined('SCRIPT_DEBUG')) {
363                 common_debug('Adding profile and remote profile ' .
364                     "for Twitter user: $profileurl");
365             }
366
367             $profile = new Profile();
368             $profile->query("BEGIN");
369
370             $profile->nickname = $user->screen_name;
371             $profile->fullname = $user->name;
372             $profile->homepage = $user->url;
373             $profile->bio = $user->description;
374             $profile->location = $user->location;
375             $profile->profileurl = $profileurl;
376             $profile->created = common_sql_now();
377
378             $id = $profile->insert();
379
380             if (empty($id)) {
381                 common_log_db_error($profile, 'INSERT', __FILE__);
382                 $profile->query("ROLLBACK");
383                 return false;
384             }
385
386             // check for remote profile
387             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
388
389             if (!$remote_pro) {
390
391                 $remote_pro = new Remote_profile();
392
393                 $remote_pro->id = $id;
394                 $remote_pro->uri = $profileurl;
395                 $remote_pro->created = common_sql_now();
396
397                 $rid = $remote_pro->insert();
398
399                 if (empty($rid)) {
400                     common_log_db_error($profile, 'INSERT', __FILE__);
401                     $profile->query("ROLLBACK");
402                     return false;
403                 }
404             }
405
406             $profile->query("COMMIT");
407
408             $this->saveAvatars($user, $id);
409
410             return $id;
411         }
412     }
413
414     function checkAvatar($twitter_user, $profile)
415     {
416         global $config;
417
418         $path_parts = pathinfo($twitter_user->profile_image_url);
419
420         $newname = 'Twitter_' . $twitter_user->id . '_' .
421             $path_parts['basename'];
422
423         $oldname = $profile->getAvatar(48)->filename;
424
425         if ($newname != $oldname) {
426
427             if (defined('SCRIPT_DEBUG')) {
428                 common_debug('Avatar for Twitter user ' .
429                     "$profile->nickname has changed.");
430                 common_debug("old: $oldname new: $newname");
431             }
432
433             $this->updateAvatars($twitter_user, $profile);
434         }
435
436         if ($this->missingAvatarFile($profile)) {
437
438             if (defined('SCRIPT_DEBUG')) {
439                 common_debug('Twitter user ' . $profile->nickname .
440                     ' is missing one or more local avatars.');
441                 common_debug("old: $oldname new: $newname");
442             }
443
444             $this->updateAvatars($twitter_user, $profile);
445         }
446
447     }
448
449     function updateAvatars($twitter_user, $profile) {
450
451         global $config;
452
453         $path_parts = pathinfo($twitter_user->profile_image_url);
454
455         $img_root = substr($path_parts['basename'], 0, -11);
456         $ext = $path_parts['extension'];
457         $mediatype = $this->getMediatype($ext);
458
459         foreach (array('mini', 'normal', 'bigger') as $size) {
460             $url = $path_parts['dirname'] . '/' .
461                 $img_root . '_' . $size . ".$ext";
462             $filename = 'Twitter_' . $twitter_user->id . '_' .
463                 $img_root . "_$size.$ext";
464
465             $this->updateAvatar($profile->id, $size, $mediatype, $filename);
466             $this->fetchAvatar($url, $filename);
467         }
468     }
469
470     function missingAvatarFile($profile) {
471
472         foreach (array(24, 48, 73) as $size) {
473
474             $filename = $profile->getAvatar($size)->filename;
475             $avatarpath = Avatar::path($filename);
476
477             if (file_exists($avatarpath) == FALSE) {
478                 return true;
479             }
480         }
481
482         return false;
483     }
484
485     function getMediatype($ext)
486     {
487         $mediatype = null;
488
489         switch (strtolower($ext)) {
490         case 'jpg':
491             $mediatype = 'image/jpg';
492             break;
493         case 'gif':
494             $mediatype = 'image/gif';
495             break;
496         default:
497             $mediatype = 'image/png';
498         }
499
500         return $mediatype;
501     }
502
503     function saveAvatars($user, $id)
504     {
505         global $config;
506
507         $path_parts = pathinfo($user->profile_image_url);
508         $ext = $path_parts['extension'];
509         $end = strlen('_normal' . $ext);
510         $img_root = substr($path_parts['basename'], 0, -($end+1));
511         $mediatype = $this->getMediatype($ext);
512
513         foreach (array('mini', 'normal', 'bigger') as $size) {
514             $url = $path_parts['dirname'] . '/' .
515                 $img_root . '_' . $size . ".$ext";
516             $filename = 'Twitter_' . $user->id . '_' .
517                 $img_root . "_$size.$ext";
518
519             if ($this->fetchAvatar($url, $filename)) {
520                 $this->newAvatar($id, $size, $mediatype, $filename);
521             } else {
522                 common_log(LOG_WARNING, "Problem fetching Avatar: $url", __FILE__);
523             }
524         }
525     }
526
527     function updateAvatar($profile_id, $size, $mediatype, $filename) {
528
529         if (defined('SCRIPT_DEBUG')) {
530             common_debug("Updating avatar: $size");
531         }
532
533         $profile = Profile::staticGet($profile_id);
534
535         if (empty($profile)) {
536             if (defined('SCRIPT_DEBUG')) {
537                 common_debug("Couldn't get profile: $profile_id!");
538             }
539             return;
540         }
541
542         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
543         $avatar = $profile->getAvatar($sizes[$size]);
544
545         // Delete the avatar, if present
546         if ($avatar) {
547             $avatar->delete();
548         }
549
550         $this->newAvatar($profile->id, $size, $mediatype, $filename);
551     }
552
553     function newAvatar($profile_id, $size, $mediatype, $filename)
554     {
555         global $config;
556
557         $avatar = new Avatar();
558         $avatar->profile_id = $profile_id;
559
560         switch($size) {
561         case 'mini':
562             $avatar->width  = 24;
563             $avatar->height = 24;
564             break;
565         case 'normal':
566             $avatar->width  = 48;
567             $avatar->height = 48;
568             break;
569         default:
570
571             // Note: Twitter's big avatars are a different size than
572             // Laconica's (Laconica's = 96)
573
574             $avatar->width  = 73;
575             $avatar->height = 73;
576         }
577
578         $avatar->original = 0; // we don't have the original
579         $avatar->mediatype = $mediatype;
580         $avatar->filename = $filename;
581         $avatar->url = Avatar::url($filename);
582
583         if (defined('SCRIPT_DEBUG')) {
584             common_debug("new filename: $avatar->url");
585         }
586
587         $avatar->created = common_sql_now();
588
589         $id = $avatar->insert();
590
591         if (empty($id)) {
592             common_log_db_error($avatar, 'INSERT', __FILE__);
593             return null;
594         }
595
596         if (defined('SCRIPT_DEBUG')) {
597             common_debug("Saved new $size avatar for $profile_id.");
598         }
599
600         return $id;
601     }
602
603     function fetchAvatar($url, $filename)
604     {
605         $avatar_dir = INSTALLDIR . '/avatar/';
606
607         $avatarfile = $avatar_dir . $filename;
608
609         $out = fopen($avatarfile, 'wb');
610         if (!$out) {
611             common_log(LOG_WARNING, "Couldn't open file $filename", __FILE__);
612             return false;
613         }
614
615         if (defined('SCRIPT_DEBUG')) {
616             common_debug("Fetching avatar: $url");
617         }
618
619         $ch = curl_init();
620         curl_setopt($ch, CURLOPT_URL, $url);
621         curl_setopt($ch, CURLOPT_FILE, $out);
622         curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
623         curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
624         curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
625         $result = curl_exec($ch);
626         curl_close($ch);
627
628         fclose($out);
629
630         return $result;
631     }
632 }
633
634 ini_set("max_execution_time", "0");
635 ini_set("max_input_time", "0");
636 set_time_limit(0);
637 mb_internal_encoding('UTF-8');
638 declare(ticks = 1);
639
640 $fetcher = new TwitterStatusFetcher();
641 $fetcher->runOnce();
642