]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - scripts/twitterstatusfetcher.php
Merge branch '0.8.x' of git@gitorious.org:+laconica-developers/laconica/dev into...
[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             $created = strftime('%Y-%m-%d %H:%M:%S',
259                                 strtotime($status->created_at));;
260
261             $notice = Notice::saveNew($id, $status->text, 'twitter',
262                                       -2, null, $uri, $created);
263
264             if (defined('SCRIPT_DEBUG')) {
265                 common_debug("Saved status $status->id" .
266                     " as notice $notice->id.");
267             }
268         }
269
270         if (!Notice_inbox::pkeyGet(array('notice_id' => $notice->id,
271                                          'user_id' => $flink->user_id))) {
272             // Add to inbox
273             $inbox = new Notice_inbox();
274             $inbox->user_id = $flink->user_id;
275             $inbox->notice_id = $notice->id;
276             $inbox->created = $notice->created;
277
278             $inbox->insert();
279         }
280     }
281
282     function ensureProfile($user)
283     {
284         // check to see if there's already a profile for this user
285         $profileurl = 'http://twitter.com/' . $user->screen_name;
286         $profile = Profile::staticGet('profileurl', $profileurl);
287
288         if ($profile) {
289             if (defined('SCRIPT_DEBUG')) {
290                 common_debug("Profile for $profile->nickname found.");
291             }
292
293             // Check to see if the user's Avatar has changed
294             $this->checkAvatar($user, $profile);
295
296             return $profile->id;
297
298         } else {
299             if (defined('SCRIPT_DEBUG')) {
300                 common_debug('Adding profile and remote profile ' .
301                     "for Twitter user: $profileurl");
302             }
303
304             $profile = new Profile();
305             $profile->query("BEGIN");
306
307             $profile->nickname = $user->screen_name;
308             $profile->fullname = $user->name;
309             $profile->homepage = $user->url;
310             $profile->bio = $user->description;
311             $profile->location = $user->location;
312             $profile->profileurl = $profileurl;
313             $profile->created = common_sql_now();
314
315             $id = $profile->insert();
316
317             if (empty($id)) {
318                 common_log_db_error($profile, 'INSERT', __FILE__);
319                 $profile->query("ROLLBACK");
320                 return false;
321             }
322
323             // check for remote profile
324             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
325
326             if (!$remote_pro) {
327
328                 $remote_pro = new Remote_profile();
329
330                 $remote_pro->id = $id;
331                 $remote_pro->uri = $profileurl;
332                 $remote_pro->created = common_sql_now();
333
334                 $rid = $remote_pro->insert();
335
336                 if (empty($rid)) {
337                     common_log_db_error($profile, 'INSERT', __FILE__);
338                     $profile->query("ROLLBACK");
339                     return false;
340                 }
341             }
342
343             $profile->query("COMMIT");
344
345             $this->saveAvatars($user, $id);
346
347             return $id;
348         }
349     }
350
351     function checkAvatar($user, $profile)
352     {
353         global $config;
354
355         $path_parts = pathinfo($user->profile_image_url);
356         $newname = 'Twitter_' . $user->id . '_' .
357             $path_parts['basename'];
358
359         $oldname = $profile->getAvatar(48)->filename;
360
361         if ($newname != $oldname) {
362
363             if (defined('SCRIPT_DEBUG')) {
364                 common_debug('Avatar for Twitter user ' .
365                     "$profile->nickname has changed.");
366                 common_debug("old: $oldname new: $newname");
367             }
368
369             $img_root = substr($path_parts['basename'], 0, -11);
370             $ext = $path_parts['extension'];
371             $mediatype = $this->getMediatype($ext);
372
373             foreach (array('mini', 'normal', 'bigger') as $size) {
374                 $url = $path_parts['dirname'] . '/' .
375                     $img_root . '_' . $size . ".$ext";
376                 $filename = 'Twitter_' . $user->id . '_' .
377                     $img_root . "_$size.$ext";
378
379                 if ($this->fetchAvatar($url, $filename)) {
380                     $this->updateAvatar($profile->id, $size, $mediatype, $filename);
381                 }
382             }
383         }
384     }
385
386     function getMediatype($ext)
387     {
388         $mediatype = null;
389
390         switch (strtolower($ext)) {
391         case 'jpg':
392             $mediatype = 'image/jpg';
393             break;
394         case 'gif':
395             $mediatype = 'image/gif';
396             break;
397         default:
398             $mediatype = 'image/png';
399         }
400
401         return $mediatype;
402     }
403
404     function saveAvatars($user, $id)
405     {
406         global $config;
407
408         $path_parts = pathinfo($user->profile_image_url);
409         $ext = $path_parts['extension'];
410         $end = strlen('_normal' . $ext);
411         $img_root = substr($path_parts['basename'], 0, -($end+1));
412         $mediatype = $this->getMediatype($ext);
413
414         foreach (array('mini', 'normal', 'bigger') as $size) {
415             $url = $path_parts['dirname'] . '/' .
416                 $img_root . '_' . $size . ".$ext";
417             $filename = 'Twitter_' . $user->id . '_' .
418                 $img_root . "_$size.$ext";
419
420             if ($this->fetchAvatar($url, $filename)) {
421                 $this->newAvatar($id, $size, $mediatype, $filename);
422             } else {
423                 common_log(LOG_WARNING, "Problem fetching Avatar: $url", __FILE__);
424             }
425         }
426     }
427
428     function updateAvatar($profile_id, $size, $mediatype, $filename) {
429
430         if (defined('SCRIPT_DEBUG')) {
431             common_debug("Updating avatar: $size");
432         }
433
434         $profile = Profile::staticGet($profile_id);
435
436         if (!$profile) {
437             if (defined('SCRIPT_DEBUG')) {
438                 common_debug("Couldn't get profile: $profile_id!");
439             }
440             return;
441         }
442
443         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
444         $avatar = $profile->getAvatar($sizes[$size]);
445
446         if ($avatar) {
447             if (defined('SCRIPT_DEBUG')) {
448                 common_debug("Deleting $size avatar for $profile->nickname.");
449             }
450             @unlink(INSTALLDIR . '/avatar/' . $avatar->filename);
451             $avatar->delete();
452         }
453
454         $this->newAvatar($profile->id, $size, $mediatype, $filename);
455     }
456
457     function newAvatar($profile_id, $size, $mediatype, $filename)
458     {
459         global $config;
460
461         $avatar = new Avatar();
462         $avatar->profile_id = $profile_id;
463
464         switch($size) {
465         case 'mini':
466             $avatar->width  = 24;
467             $avatar->height = 24;
468             break;
469         case 'normal':
470             $avatar->width  = 48;
471             $avatar->height = 48;
472             break;
473         default:
474
475             // Note: Twitter's big avatars are a different size than
476             // Laconica's (Laconica's = 96)
477
478             $avatar->width  = 73;
479             $avatar->height = 73;
480         }
481
482         $avatar->original = 0; // we don't have the original
483         $avatar->mediatype = $mediatype;
484         $avatar->filename = $filename;
485         $avatar->url = Avatar::url($filename);
486
487         if (defined('SCRIPT_DEBUG')) {
488             common_debug("new filename: $avatar->url");
489         }
490
491         $avatar->created = common_sql_now();
492
493         $id = $avatar->insert();
494
495         if (!$id) {
496             common_log_db_error($avatar, 'INSERT', __FILE__);
497             return null;
498         }
499
500         if (defined('SCRIPT_DEBUG')) {
501             common_debug("Saved new $size avatar for $profile_id.");
502         }
503
504         return $id;
505     }
506
507     function fetchAvatar($url, $filename)
508     {
509         $avatar_dir = INSTALLDIR . '/avatar/';
510
511         $avatarfile = $avatar_dir . $filename;
512
513         $out = fopen($avatarfile, 'wb');
514         if (!$out) {
515             common_log(LOG_WARNING, "Couldn't open file $filename", __FILE__);
516             return false;
517         }
518
519         if (defined('SCRIPT_DEBUG')) {
520             common_debug("Fetching avatar: $url");
521         }
522
523         $ch = curl_init();
524         curl_setopt($ch, CURLOPT_URL, $url);
525         curl_setopt($ch, CURLOPT_FILE, $out);
526         curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
527         curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
528         curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
529         $result = curl_exec($ch);
530         curl_close($ch);
531
532         fclose($out);
533
534         return $result;
535     }
536 }
537
538 ini_set("max_execution_time", "0");
539 ini_set("max_input_time", "0");
540 set_time_limit(0);
541 mb_internal_encoding('UTF-8');
542 declare(ticks = 1);
543
544 $fetcher = new TwitterStatusFetcher();
545 $fetcher->runOnce();
546