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