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