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