]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - scripts/twitterstatusfetcher.php
Store better data about twitter statuses
[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 /**
42  * Fetcher for statuses from Twitter
43  *
44  * Fetches statuses from Twitter and inserts them as notices in local
45  * system.
46  *
47  * @category Twitter
48  * @package  Laconica
49  * @author   Zach Copley <zach@controlyourself.ca>
50  * @author   Evan Prodromou <evan@controlyourself.ca>
51  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
52  * @link     http://laconi.ca/
53  */
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($user, $profile)
407     {
408         global $config;
409
410         $path_parts = pathinfo($user->profile_image_url);
411         $newname = 'Twitter_' . $user->id . '_' .
412             $path_parts['basename'];
413
414         $oldname = $profile->getAvatar(48)->filename;
415
416         if ($newname != $oldname) {
417
418             if (defined('SCRIPT_DEBUG')) {
419                 common_debug('Avatar for Twitter user ' .
420                     "$profile->nickname has changed.");
421                 common_debug("old: $oldname new: $newname");
422             }
423
424             $img_root = substr($path_parts['basename'], 0, -11);
425             $ext = $path_parts['extension'];
426             $mediatype = $this->getMediatype($ext);
427
428             foreach (array('mini', 'normal', 'bigger') as $size) {
429                 $url = $path_parts['dirname'] . '/' .
430                     $img_root . '_' . $size . ".$ext";
431                 $filename = 'Twitter_' . $user->id . '_' .
432                     $img_root . "_$size.$ext";
433
434                 if ($this->fetchAvatar($url, $filename)) {
435                     $this->updateAvatar($profile->id, $size, $mediatype, $filename);
436                 }
437             }
438         }
439     }
440
441     function getMediatype($ext)
442     {
443         $mediatype = null;
444
445         switch (strtolower($ext)) {
446         case 'jpg':
447             $mediatype = 'image/jpg';
448             break;
449         case 'gif':
450             $mediatype = 'image/gif';
451             break;
452         default:
453             $mediatype = 'image/png';
454         }
455
456         return $mediatype;
457     }
458
459     function saveAvatars($user, $id)
460     {
461         global $config;
462
463         $path_parts = pathinfo($user->profile_image_url);
464         $ext = $path_parts['extension'];
465         $end = strlen('_normal' . $ext);
466         $img_root = substr($path_parts['basename'], 0, -($end+1));
467         $mediatype = $this->getMediatype($ext);
468
469         foreach (array('mini', 'normal', 'bigger') as $size) {
470             $url = $path_parts['dirname'] . '/' .
471                 $img_root . '_' . $size . ".$ext";
472             $filename = 'Twitter_' . $user->id . '_' .
473                 $img_root . "_$size.$ext";
474
475             if ($this->fetchAvatar($url, $filename)) {
476                 $this->newAvatar($id, $size, $mediatype, $filename);
477             } else {
478                 common_log(LOG_WARNING, "Problem fetching Avatar: $url", __FILE__);
479             }
480         }
481     }
482
483     function updateAvatar($profile_id, $size, $mediatype, $filename) {
484
485         if (defined('SCRIPT_DEBUG')) {
486             common_debug("Updating avatar: $size");
487         }
488
489         $profile = Profile::staticGet($profile_id);
490
491         if (!$profile) {
492             if (defined('SCRIPT_DEBUG')) {
493                 common_debug("Couldn't get profile: $profile_id!");
494             }
495             return;
496         }
497
498         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
499         $avatar = $profile->getAvatar($sizes[$size]);
500
501         if ($avatar) {
502             if (defined('SCRIPT_DEBUG')) {
503                 common_debug("Deleting $size avatar for $profile->nickname.");
504             }
505             @unlink(INSTALLDIR . '/avatar/' . $avatar->filename);
506             $avatar->delete();
507         }
508
509         $this->newAvatar($profile->id, $size, $mediatype, $filename);
510     }
511
512     function newAvatar($profile_id, $size, $mediatype, $filename)
513     {
514         global $config;
515
516         $avatar = new Avatar();
517         $avatar->profile_id = $profile_id;
518
519         switch($size) {
520         case 'mini':
521             $avatar->width  = 24;
522             $avatar->height = 24;
523             break;
524         case 'normal':
525             $avatar->width  = 48;
526             $avatar->height = 48;
527             break;
528         default:
529
530             // Note: Twitter's big avatars are a different size than
531             // Laconica's (Laconica's = 96)
532
533             $avatar->width  = 73;
534             $avatar->height = 73;
535         }
536
537         $avatar->original = 0; // we don't have the original
538         $avatar->mediatype = $mediatype;
539         $avatar->filename = $filename;
540         $avatar->url = Avatar::url($filename);
541
542         if (defined('SCRIPT_DEBUG')) {
543             common_debug("new filename: $avatar->url");
544         }
545
546         $avatar->created = common_sql_now();
547
548         $id = $avatar->insert();
549
550         if (!$id) {
551             common_log_db_error($avatar, 'INSERT', __FILE__);
552             return null;
553         }
554
555         if (defined('SCRIPT_DEBUG')) {
556             common_debug("Saved new $size avatar for $profile_id.");
557         }
558
559         return $id;
560     }
561
562     function fetchAvatar($url, $filename)
563     {
564         $avatar_dir = INSTALLDIR . '/avatar/';
565
566         $avatarfile = $avatar_dir . $filename;
567
568         $out = fopen($avatarfile, 'wb');
569         if (!$out) {
570             common_log(LOG_WARNING, "Couldn't open file $filename", __FILE__);
571             return false;
572         }
573
574         if (defined('SCRIPT_DEBUG')) {
575             common_debug("Fetching avatar: $url");
576         }
577
578         $ch = curl_init();
579         curl_setopt($ch, CURLOPT_URL, $url);
580         curl_setopt($ch, CURLOPT_FILE, $out);
581         curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
582         curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
583         curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
584         $result = curl_exec($ch);
585         curl_close($ch);
586
587         fclose($out);
588
589         return $result;
590     }
591 }
592
593 ini_set("max_execution_time", "0");
594 ini_set("max_input_time", "0");
595 set_time_limit(0);
596 mb_internal_encoding('UTF-8');
597 declare(ticks = 1);
598
599 $fetcher = new TwitterStatusFetcher();
600 $fetcher->runOnce();
601