#!/usr/bin/php . */ $SNAME='GancioFF'; $ENAME=strtolower($SNAME); $SVERS='0.1'; require __DIR__.'/lib/gettlds.php'; require __DIR__.'/lib/mastodon-postLength.php'; require __DIR__.'/lib/hashtag.php'; require __DIR__.'/lib/html2text.php'; require __DIR__.'/lib/curl.php'; $help= "[[[ SYNOPSIS ]]] {$ENAME} [options] [[[ DESCRIPTION ]]] This is {$SNAME} v{$SVERS}, a CLI PHP script that can be used to periodically fetch the RSS feed from an instance of Gancio (https://gancio.org) and post its new entries – the events – on the fediverse through a Mastodon account, keeping track of already posted events’ GUIDs (Global Unique IDentifiers) in order to post only the new ones on each run. It can be useful, for example, when the admins of a Gancio instance chose not to use its federation feature because it would be too heavy on its server: in this case {$SNAME} is a quite light alternative, moving from the server running Gancio to the one running Mastodon the burden of posting each event to all the instances that host at least one follower, and of sending them the image a Gancio user can and almost always do attach to each event, because {$SNAME} will fetch it only once and attach it to the post for the event. {$SNAME} is meant to be periodically run, every half an hour or so, by a cron job, or systemd timer, or the likes (you can find a sample «{$ENAME}.timer» and a commented sample «{$ENAME}.service» in the «systemd» directory). In order to work, {$SNAME} needs a configuration file path to be passed to it as an argument on the command line. [[[ CONFIGURATION FILE ]]] The configuration file needs to be like this: --- Example configuration file --- # Lines beginnig with a «#» and empty lines will be ignored # «feed_url» is required to specify the URL to fetch the RSS feed from; # for example: feed_url = https://gancio.some.domain/feed/rss?show_recurrent=true # «fedi_hostname» is required to specify the hostname of the Mastodon instance # you want to post to; for example: fedi_hostname = mastodon.another.domain # «fedi_token» is required to specify an «app token» to access the account # that you want to use on the instance defined by «fedi_hostname». On Mastodon # default web frontend you can get such a token under «Preferences» -> # «Development», by clicking on the «New application» button; the new # application should have at least the «write:media» and «write:statuses» # privileges; when you’ll be done setting it up, it will be listed under # «Your applications», and by clicking on its name you’ll be able to copy # «Your access token» and paste it here. For example: fedi_token = w6oQ_Ot2LSAm_Q31hrvp0asfl22ip3O4ipYq1kV1ceY # «state_file_absolute_path» is required to specify the absolute path of the # state file where {$SNAME} will store the GUIDs of already posted events and # the timestamps of the moments when it posted them (on each run, {$SNAME} # will check for entries older than one year and discard them, in order to # avoid the state file to grow too much). For example: state_file_absolute_path = /var/local/cache/gancio.some.domain.feed.state # «posts_language» is required to specify the ISO 639-1 code for the language # of posts (see https://www.loc.gov/standards/iso639-2/php/code_list.php for # a complete list); for example: posts_language = it # «posts_visibility» is optional and lets you override the default “public” # visibility of posts; it can be set to «public» (posts will be visible in the # «Local» and «Federated» timelines, and any user will be able to boost them), # «unlisted» (posts will be visible only in the «Home» timeline of followers # and on the profile of the Mastodon account in use, not in the «Local» or # «Federated» timelines, but any user will still be able to boost them), # «private» (AKA «followers only»: posts will be visible only by followers and # won’t be boostable by anyone), and «direct» (since {$SNAME} posts won’t ever # explicitly mention any account, posts with this visibility will be visible # only from the Mastodon account in use). For example: post_visibility = unlisted # «max_post_length» is optional and lets you override the automatically # detected maximum length that a post can have on the instance specified with # «fedi_hostname»; it can be used for testing purposes or just to keep the # posts shorter than they would be otherwise; for example: max_post_length = 840 --- End of example configuration file --- [[[ OPTIONS ]]] -h / --help Show this help text and exit. -p / --do-post Setting this option to «n» («no») will make {$SNAME} skip posting. Note that even in this case it will save into the state file the GUIDs of new events it may find in the feed, so it won’t post them even on the subsequent runs. You may want to set this option to «n» on the first run on a given feed, i.e. when the state file doesn’t exist yet and all events in the feed will be considered new, to avoid flooding the timeline of the given Mastodon instance. That’s why when the state file doesn’t exist yet, {$SNAME} refuses to run unless you explicitly set this option to «n» («no») or «y» («yes»). When the state file exists, this option defaults to «y» («yes»). -v / --verbose Show some more messages about what the script is doing. -- Treat every possible subsequent argument as non-options. Useful only in the very improbable case your config file is named «--help» or as another option. [[[ EXIT VALUES ]]] 0: regular run 1: some error occurred 99: killed with signal (ctrl+c, etc.) [[[ DISCLAIMER AND LICENSE ]]] This program comes with ABSOLUTELY NO WARRANTY; for details see the source. This is free software, and you are welcome to redistribute it under certain conditions; see for details.\n"; $confFP=null; $conf=[ 'feed_url'=>['required'=>true, 'default'=>null], 'fedi_hostname'=>['required'=>true, 'default'=>null], 'fedi_token'=>['required'=>true, 'default'=>null], 'state_file_absolute_path'=>['required'=>true, 'default'=>null], 'posts_language'=>['required'=>true, 'default'=>null], 'posts_visibility'=>['required'=>false, 'default'=>'public'], 'max_post_length'=>['required'=>false, 'default'=>null] ]; $opts=[ 'do-post'=>null, 'verbose'=>false, 'update-language-codes'=>false ]; $canBeOpt=true; for ($i=1; $i<$argc; $i++) { if ($canBeOpt && $argv[$i][0]=='-') { if ($argv[$i]=='--') { $canBeOpt=false; } elseif ($argv[$i]=='-h' || $argv[$i]=='--help') { echo $help; exit(0); } elseif ($argv[$i]=='-p' || $argv[$i]=='--do-post') { if ($i+1>=$argc) dieYoung("Error: option «{$argv[$i]}» requires an argument; use «-h» or «--help» to display help.\n",1); if ($argv[$i+1]=='y') $opts['do-post']=true; elseif ($argv[$i+1]=='n') $opts['do-post']=false; else dieYoung("Error: option «{$argv[$i]}» requires an argument of «y» for «yes» or «n» for «no»; use «-h» or «--help» to display help.\n",1); $i++; } elseif ($argv[$i]=='-v' || $argv[$i]=='--verbose') { $opts['verbose']=true; } elseif ($argv[$i]=='-u' || $argv[$i]=='--update-language-codes') { $opts['update-language-codes']=true; } elseif ($argv[$i]=='--make-readme') { file_put_contents(__DIR__.'/README.md',"```text\n{$help}```\n"); exit(0); } else { dieYoung("Error: «{$argv[$i]}» is not a known option; use «-h» or «--help» to display help.\n",1); } } elseif (is_null($confFP)) { $confFP=$argv[$i]; } else { dieYoung("Error: could not interpret «{$argv[$i]}» (configuration file has already been set to «{$confFP}»); use «-h» or «--help» to display help.\n",1); } } //declare(ticks=1); pcntl_async_signals(true); pcntl_signal(SIGTERM,'sighandler');// Termination ('kill' was called) pcntl_signal(SIGHUP,'sighandler');// Terminal log-out pcntl_signal(SIGINT,'sighandler');// Interrupted (ctrl-c is pressed) $langsFP=__DIR__.'/lib/iso-639-1-langcodes.txt'; if ($opts['update-language-codes']) { $langCodes=[]; $url='https://www.loc.gov/standards/iso639-2/ISO-639-2_8859-1.txt'; $res=curl($url); if ($res['content']===false) dieYoung("Error: could not connect to «{$url}».\n",1); if ($res['httpcode']!='200') dieYoung("Error: got http code «{$res['httpcode']}» from «{$url}».\n",1); $res=explode("\r\n",$res['content']); // alb|sqi|sq|Albanian|albanais // tup|||Tupi languages|tupi, langues foreach ($res as $val) if (preg_match('#^[a-z]{3}\|([a-z]{3})?\|([a-z]{2})\|.+\|.+$#',$val,$matches)===1) $langCodes[]=$matches[2]; $count=count($langCodes); if (@file_put_contents($langsFP,implode("\n",$langCodes)."\n")===false) dieYoung("Error: could not save the {$count} ISO 639-1 language code(s) i got from «{$url}» into «{$langsFP}».\n",1); echo "Info: successfully saved the {$count} ISO 639-1 language code(s) i got from «{$url}» into «{$langsFP}».\n"; exit(0); } if (($langs=@file($langsFP,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES))===false) dieYoung("Error: could not load ISO 639-1 language codes from «{$langsFP}».\n",1); if (is_null($confFP)) dieYoung("Error: you have not specified a configuration file; use «-h» or «--help» to display help.\n",1); echo "Info: trying to load configuration file «{$confFP}» from directory «".getcwd()."».\n"; if (!file_exists($confFP)) dieYoung("Error: «{$confFP}» does not exist.\n",1); if (!is_file($confFP)) dieYoung("Error: «{$confFP}» is not a file.\n",1); if (!is_readable($confFP)) dieYoung("Error: «{$confFP}» is not readable.\n",1); getConf($conf,$confFP); if (preg_match('#^/.*$#',$conf['state_file_absolute_path'])!==1) dieYoung("Error: in configuration file: «state_file_absolute_path» must be an absolute path.\n",1); if (!in_array($conf['posts_language'],$langs)) dieYoung("Error: in configuration file: «posts_language»: «{$conf['posts_language']}» is not a known language code.\n",1); if (!in_array($conf['posts_visibility'],['public', 'unlisted', 'private', 'direct'])) dieYoung("Error: in configuration file: «posts_visibility» must be one of «public», «unlisted», «private» or «direct».\n",1); if (!is_null($conf['max_post_length'])) { if (preg_match('#^\d+$#',$conf['max_post_length'])!==1 || $conf['max_post_length']+0<10) dieYoung("Error: configuration file: «max_post_length» must be an integer greater than or equal to 10.\n",1); $conf['max_post_length']+=0; echo "Info: got «{$conf['max_post_length']}» as «max_post_length» from configuration file.\n"; } $tldsregex=gettlds(__DIR__.'/storage/tlds.txt',true); $url="https://{$conf['fedi_hostname']}/api/v2/instance"; echo "Info: trying to fetch instance info from «{$url}».\n"; $res=curl($url,null,["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json']); if ($res['content']===false) dieYoung("Error: could not connect to «{$url}» (error: «{$res['error']}»).\n",1); $res['content']=@json_decode($res['content'],true); if (is_null($res['content'])) dieYoung("Error: content from «{$url}» was not good JSON.\n",1); (isset($res['content']['error'])) ? $buff=" («{$res['content']['error']}»)" : $buff=''; if ($res['httpcode']!='200') dieYoung("Error: got http code «{$res['httpcode']}»{$buff} from «{$url}».\n",1); if (!isset($res['content']['configuration']['media_attachments']['image_size_limit'])) dieYoung("Error: JSON from «{$url}» doesn’t declare «image_size_limit».\n",1); if (!is_int($res['content']['configuration']['media_attachments']['image_size_limit'])) dieYoung("Error: JSON from «{$url}» declares «image_size_limit» with an unexpected format.\n",1); $conf['max_image_size']=$res['content']['configuration']['media_attachments']['image_size_limit']+0; echo "Info: got «{$conf['max_image_size']}» as «max_image_size» from «{$url}».\n"; if (!isset($res['content']['configuration']['statuses']['max_characters'])) dieYoung("Error: JSON from «{$url}» doesn’t declare «max_characters».\n",1); if (!is_int($res['content']['configuration']['statuses']['max_characters'])) dieYoung("Error: JSON from «{$url}» declares «max_characters» with an unexpected format.\n",1); if (is_null($conf['max_post_length'])) { $conf['max_post_length']=$res['content']['configuration']['statuses']['max_characters']+0; echo "Info: got «{$conf['max_post_length']}» as «max_post_length» from «{$url}».\n"; } //print_r($conf); echo "Info: trying to fetch feed from «{$conf['feed_url']}».\n"; $feed=curl($conf['feed_url'],null,['Accept: application/xml']); if ($feed['content']===false) dieYoung("Error: could not connect to «{$conf['feed_url']}» (error: «{$feed['error']}»).\n",1); if ($feed['httpcode']!='200') dieYoung("Error: «{$conf['feed_url']} returned http code «{$res['httpcode']}».\n",1); $feed=@simplexml_load_string($feed['content'],null,LIBXML_NOCDATA); if ($feed===false) dieYoung("Error: got no valid XML from «{$conf['feed_url']}».\n",1); //print_r($feed); if (!isset($feed->channel->item) || !is_iterable($feed->channel->item) || !is_countable($feed->channel->item)) dieYoung("Error: feed from «{$conf['feed_url']}» had unexpected format.\n",1); $itemsCount=$feed->channel->item->count(); if ($itemsCount==0) exitYoung("Info: feed from «{$conf['feed_url']}» was empty, bye.\n"); echo "Info: got good feed from «{$conf['feed_url']}».\n"; $guids=[]; echo "Info: trying to load GUIDs of already posted events from state file «{$conf['state_file_absolute_path']}».\n"; if (!file_exists($conf['state_file_absolute_path']) && is_null($opts['do-post'])) dieYoung("Error: state file «{$conf['state_file_absolute_path']}» does not exist: this seems to be a first run on feed «{$conf['feed_url']}», so you should explicitly declare whether you want {$SNAME} to post all of its events or not, setting option «-p» or «--do-post» to «y» («yes») or «n» («no»); use «-h» or «--help» to display help.\n",1); if (is_null($opts['do-post'])) $opts['do-post']=true; if (file_exists($conf['state_file_absolute_path'])) { if (!is_file($conf['state_file_absolute_path'])) dieYoung("Error: «{$conf['state_file_absolute_path']}» exists but it’s not a file.\n",1); if (!is_readable($conf['state_file_absolute_path'])) dieYoung("Error: «{$conf['state_file_absolute_path']}» exists but it’s not readable.\n",1); if (!is_writeable($conf['state_file_absolute_path'])) dieYoung("Error: «{$conf['state_file_absolute_path']}» exists but it’s not writable.\n",1); $guids=[]; $buff=file($conf['state_file_absolute_path'],FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES); $graceTime=365*24*60*60; $graceLine=time()-$graceTime; $i=0; $fh=fopen($conf['state_file_absolute_path'],'w'); foreach ($buff as $key=>$val) { if (preg_match('#^(\d+)\t(\S+)$#',$val,$matches)===1) { if ($matches[1]+0>=$graceLine) { fwrite($fh,"{$matches[1]}\t{$matches[2]}\n"); $guids[$matches[2]]=$matches[1]; } else { $i++; } } else { fwrite(STDERR,"Warning: in state file «{$conf['state_file_absolute_path']}», line ".($key+1)." had unexpected format.\n"); } } fclose($fh); echo 'Info: got '.count($guids)." GUID(s) for already posted event(s) from state file «{$conf['state_file_absolute_path']}»; removed {$i} line(s) older than one year.\n"; } if (($fh=@fopen($conf['state_file_absolute_path'],'a'))===false) dieYoung("Error: could not open «{$conf['state_file_absolute_path']}» in «append» mode.\n",1); $newItemsCount=0; $goodPostsCount=0; $index=0; foreach ($feed->channel->item as $item) { $index++; // print_r($item); if (!isset($item->guid) || ($guid=$item->guid->__toString())=='') { fwrite(STDERR,"Warning: event #{$index} has no GUID, skipping.\n"); } elseif (!array_key_exists($guid,$guids)) { $newItemsCount++; $file=null; if (isset($item->enclosure[0]['url']) && isset($item->enclosure[0]['type']) && isset($item->enclosure[0]['length'])) $file=['url'=>$item->enclosure[0]['url']->__toString(), 'type'=>$item->enclosure[0]['type']->__toString(), 'length'=>$item->enclosure[0]['length']->__toString()]; if (isset($item->description)) { $buff=$item->description->__toString(); if ($buff=='') { $ptext=''; } elseif (preg_match('#^\n?

(.+)

(.+)
\((\w+)\W+(\d+)\W+(\w+)\W+(\d+:\d+)\)
(.+)$#iuU',$buff,$matches)===1) { // print_r($matches); $matches[1]=hent($matches[1]); $matches[2]=hent($matches[2]); $ptext="{$matches[1]}\n\n".ucfirst($matches[3])." {$matches[4]} {$matches[5]} alle {$matches[6]} presso {$matches[2]}\n\n".html2text($matches[7]); } else { $ptext=html2text($item->description); } } if (isset($item->link) && $item->link->__toString()!='') { $plink="\n\n".$item->link->__toString(); } else { $plink=''; } if (isset($item->category) && is_countable($item->category) && is_iterable($item->category) && $item->category->count()>0) { $pcats=[]; foreach ($item->category as $val) $pcats[]=hashtag($val->__toString()); $pcats="\n\n".implode(' ',$pcats); } else { $pcats=''; } $post="{$ptext}{$plink}{$pcats}"; while (postLength($post,$tldsregex['tlds'])>$conf['max_post_length'] && $ptext!='') { $ptext=preg_replace('#\S+\W*$#','',$ptext); // echo "[[[{$ptext}]]]\n"; $post="{$ptext}[…]{$plink}{$pcats}"; } if (postLength($post,$tldsregex['tlds'])>$conf['max_post_length']) $post=$plink; if (postLength($post,$tldsregex['tlds'])>$conf['max_post_length']) { fwrite(STDERR,"Warning: could not shorten post for event «{$guid}» to make it fit into {$conf['max_post_length']} characters; will not post.\n"); } else { if ($opts['verbose']) echo "--- #{$index}: {$guid} ---\n{$post}\n--- (length: ".postLength($post,$tldsregex['tlds']).") ---\n\n"; if ($opts['do-post']) { $postData=[]; // print_r($file); if (is_null($file)) { if ($opts['verbose']) echo "Info: event «{$guid}» has no attachment.\n"; } elseif ($file['length']>$conf['max_image_size']) { fwrite(STDERR,"Warning: ignoring attachment of event «{$guid}» because its size is greater than image upload max size on «{$conf['fedi_hostname']}».\n"); } else { $res=curl($file['url']); if ($res['content']===false) { fwrite(STDERR,"Warning: could not connect to «{$file['url']}» (error: «{$res['error']}»); won’t post file as attachment.\n"); } elseif ($res['httpcode']!='200') { fwrite(STDERR,"Warning: «{$file['url']}» returned http code «{$res['httpcode']}»; won’t post file as attachment.\n"); } else { $pd=['file'=>new CURLStringFile($res['content'],'file',$file['type']), 'description'=>'Flyer dell’evento']; $url="https://{$conf['fedi_hostname']}/api/v2/media"; $res=curl($url,'/api/v2/media',["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json'],$pd); if ($res['content']===false) { fwrite(STDERR,"Warning: could not connect to «{$url}» (error: «{$res['error']}»); won’t post file as attachment.\n"); } elseif (is_null($res['content']=@json_decode($res['content'],true))) { fwrite(STDERR,"Warning: «{$url}» did not return valid JSON; won’t post file as attachment.\n"); } elseif ($res['httpcode']!='200' && $res['httpcode']!='202') { (isset($res['content']['error'])) ? $buff=" (error: «{$res['content']['error']}»)" : $buff=''; fwrite(STDERR,"Warning: «{$url}» returned http code «{$res['httpcode']}»{$buff}; won’t post file as attachment.\n"); } elseif (!isset($res['content']['id'])) { fwrite(STDERR,"Warning: no «id» in JSON from «{$url}»; won’t post file as attachment.\n"); } else { $id=$res['content']['id']; if ($res['httpcode']=='202') { $id=null; $i=1; while ($res['httpcode']!='200' && $i<5) { sleep(2); $url="https://{$conf['fedi_hostname']}/api/v1/media/{$res['id']}"; $res=curl($url,'/api/v1/media',["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json']); if ($res['content']!==false && $res['httpcode']=='200' && !is_null($res['content']=@json_decode($res,true)) && isset($res['content']['id'])) $id=$res['content']['id']; $i++; } } if (!is_null($id)) $postData['media_ids[]']=$id; else fwrite(STDERR,"Warning: server took too long to process file, or could not; won’t post file as attachment.\n"); } } } $postData['status']=$post; $postData['visibility']=$conf['posts_visibility']; $postData['language']='it'; $url="https://{$conf['fedi_hostname']}/api/v1/statuses"; $headers=["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json', 'Idempotency-Key: '.md5(implode('-',$postData).time())]; $res=curl($url,'/api/v1/statuses',$headers,$postData); if ($res['content']===false) { fwrite(STDERR,"Warning: could not connect to «{$url}» (error: «{$res['error']}»); could not post status for event «{$guid}».\n"); } elseif (is_null($res['content']=@json_decode($res['content'],true))) { fwrite(STDERR,"Warning: «{$url}» did not return good JSON; could not post status for event «{$guid}».\n"); } elseif ($res['httpcode']!='200') { (isset($res['content']['error'])) ? $buff=" (error: «{$res['content']['error']}»)" : $buff=''; fwrite(STDERR,"Warning: «{$url}» returned http code «{$res['httpcode']}»{$buff}; could not post status for event «{$guid}».\n"); } elseif (!isset($res['content']['url'])) { fwrite(STDERR,"Warning: JSON from «{$url}» had unexpected format; could not post status for event «{$guid}».\n"); } else { echo "Info: successfully posted status for event «{$guid}» (URL: «{$res['content']['url']}»).\n"; // print_r($res['content']); $now=time(); $guids[$guid]=$now; fwrite($fh,"{$now}\t{$guid}\n"); $goodPostsCount++; } } else { echo "Info: would have posted status for event «{$guid}».\n"; $now=time(); $guids[$guid]=$now; fwrite($fh,"{$now}\t{$guid}\n"); $goodPostsCount++; } } } else { if ($opts['verbose']) echo "Info: event «{$guid}» has already been posted on ".date('c',$guids[$guid]).", skipping.\n"; } // fclose($fh); exit(0);// to test a single post } fclose($fh); if ($opts['do-post']) echo "Info: feed got {$itemsCount} events; succesfully posted {$goodPostsCount} of {$newItemsCount} new event(s).\n"; else echo "Info: feed got {$itemsCount} event(s), {$newItemsCount} new.\n"; exit(0); function getConf(&$conf,&$confFP) { $errors=[]; $nconf=[]; $buff=@file($confFP,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES); if ($buff===false) dieYoung("Error: could not read configuration file «$confFP}».\n",1); $i=0; foreach ($buff as $line) { $i++; if ($line[0]!=='#') { if (preg_match('#^([^=]+)=(.+)$#',$line,$matches)===1) { $matches[1]=trim($matches[1]); $matches[2]=trim($matches[2]); if (array_key_exists($matches[1],$conf)) $nconf[$matches[1]]=$matches[2]; else $errors[]="line {$i}: «{$matches[1]}» is an unknown key.\n"; } else { $errors[]="could not interpret line {$i} («{$line}»)"; } } } //print_r($nconf); foreach ($conf as $key=>$val) { if ($conf[$key]['required'] && !array_key_exists($key,$nconf)) $errors[]="«{$key}» is not defined"; if (array_key_exists($key,$nconf)) $conf[$key]=$nconf[$key]; else $conf[$key]=$conf[$key]['default']; } $errorsCount=count($errors); if ($errorsCount>0) { fwrite(STDERR,"Sorry, there are errors in configuration file «{$confFP}»:\n"); for ($i=1; $i<=$errorsCount; $i++) fwrite(STDERR," {$i}. {$errors[$i-1]}\n"); fwrite(STDERR,"Use «-h» or «--help» to display help.\n"); exit(1); } } function cknap($napid) { global $naps; $now=time(); if (isset($naps[$napid]) && $naps[$napid]>$now) { $sleepsecs=$naps[$napid]-$now; echo "Info: reached rate limit on «{$napid}»; sleeping until ".date('c',$naps[$napid]).' ...'; sleep($sleepsecs); echo "\n"; $naps[$napid]=0; } } function hent($str) { return html_entity_decode($str,ENT_QUOTES,'UTF-8'); } function sighandler($sig) { global $fh; if (isset($fh)) fclose($fh); echo "\nInfo: received signal {$sig}, shutting down.\n"; exit(99); } function dieYoung($msg,$ec) { fwrite(STDERR,$msg); die($ec); } function exitYoung($msg) { echo $msg; exit(0); } ?>