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