GeonamesPlugin.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. <?php
  2. /**
  3. * StatusNet, the distributed open-source microblogging tool
  4. *
  5. * Plugin to convert string locations to Geonames IDs and vice versa
  6. *
  7. * PHP version 5
  8. *
  9. * LICENCE: This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as published by
  11. * the Free Software Foundation, either version 3 of the License, or
  12. * (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. *
  22. * @category Action
  23. * @package StatusNet
  24. * @author Evan Prodromou <evan@status.net>
  25. * @copyright 2009 StatusNet Inc.
  26. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
  27. * @link http://status.net/
  28. */
  29. if (!defined('STATUSNET')) {
  30. exit(1);
  31. }
  32. /**
  33. * Plugin to convert string locations to Geonames IDs and vice versa
  34. *
  35. * This handles most of the events that Location class emits. It uses
  36. * the geonames.org Web service to convert names like 'Montreal, Quebec, Canada'
  37. * into IDs and lat/lon pairs.
  38. *
  39. * @category Plugin
  40. * @package StatusNet
  41. * @author Evan Prodromou <evan@status.net>
  42. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
  43. * @link http://status.net/
  44. *
  45. * @seeAlso Location
  46. */
  47. class GeonamesPlugin extends Plugin
  48. {
  49. const LOCATION_NS = 1;
  50. public $host = 'ws.geonames.org';
  51. public $username = null;
  52. public $token = null;
  53. public $expiry = 7776000; // 90-day expiry
  54. public $timeout = 2; // Web service timeout in seconds.
  55. public $timeoutWindow = 60; // Further lookups in this process will be disabled for N seconds after a timeout.
  56. public $cachePrefix = null; // Optional shared memcache prefix override
  57. // to share lookups between local instances.
  58. protected $lastTimeout = null; // timestamp of last web service timeout
  59. /**
  60. * convert a name into a Location object
  61. *
  62. * @param string $name Name to convert
  63. * @param string $language ISO code for anguage the name is in
  64. * @param Location &$location Location object (may be null)
  65. *
  66. * @return boolean whether to continue (results in $location)
  67. */
  68. function onLocationFromName($name, $language, &$location)
  69. {
  70. $loc = $this->getCache(array('name' => $name,
  71. 'language' => $language));
  72. if ($loc !== false) {
  73. $location = $loc;
  74. return false;
  75. }
  76. try {
  77. $geonames = $this->getGeonames('search',
  78. array('maxRows' => 1,
  79. 'q' => $name,
  80. 'lang' => $language,
  81. 'type' => 'xml'));
  82. } catch (Exception $e) {
  83. $this->log(LOG_WARNING, "Error for $name: " . $e->getMessage());
  84. return true;
  85. }
  86. if (count($geonames) == 0) {
  87. // no results
  88. $this->setCache(array('name' => $name,
  89. 'language' => $language),
  90. null);
  91. return true;
  92. }
  93. $n = $geonames[0];
  94. $location = new Location();
  95. $location->lat = $this->canonical($n->lat);
  96. $location->lon = $this->canonical($n->lng);
  97. $location->names[$language] = (string)$n->name;
  98. $location->location_id = (string)$n->geonameId;
  99. $location->location_ns = self::LOCATION_NS;
  100. $this->setCache(array('name' => $name,
  101. 'language' => $language),
  102. $location);
  103. // handled, don't continue processing!
  104. return false;
  105. }
  106. /**
  107. * convert an id into a Location object
  108. *
  109. * @param string $id Name to convert
  110. * @param string $ns Name to convert
  111. * @param string $language ISO code for language for results
  112. * @param Location &$location Location object (may be null)
  113. *
  114. * @return boolean whether to continue (results in $location)
  115. */
  116. function onLocationFromId($id, $ns, $language, &$location)
  117. {
  118. if ($ns != self::LOCATION_NS) {
  119. // It's not one of our IDs... keep processing
  120. return true;
  121. }
  122. $loc = $this->getCache(array('id' => $id));
  123. if ($loc !== false) {
  124. $location = $loc;
  125. return false;
  126. }
  127. try {
  128. $geonames = $this->getGeonames('hierarchy',
  129. array('geonameId' => $id,
  130. 'lang' => $language));
  131. } catch (Exception $e) {
  132. $this->log(LOG_WARNING, "Error for ID $id: " . $e->getMessage());
  133. return false;
  134. }
  135. $parts = array();
  136. foreach ($geonames as $level) {
  137. if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
  138. $parts[] = (string)$level->name;
  139. }
  140. }
  141. $last = $geonames[count($geonames)-1];
  142. if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
  143. $parts[] = (string)$last->name;
  144. }
  145. $location = new Location();
  146. $location->location_id = (string)$last->geonameId;
  147. $location->location_ns = self::LOCATION_NS;
  148. $location->lat = $this->canonical($last->lat);
  149. $location->lon = $this->canonical($last->lng);
  150. $location->names[$language] = implode(', ', array_reverse($parts));
  151. $this->setCache(array('id' => (string)$last->geonameId),
  152. $location);
  153. // We're responsible for this namespace; nobody else
  154. // can resolve it
  155. return false;
  156. }
  157. /**
  158. * convert a lat/lon pair into a Location object
  159. *
  160. * Given a lat/lon, we try to find a Location that's around
  161. * it or nearby. We prefer populated places (cities, towns, villages).
  162. *
  163. * @param string $lat Latitude
  164. * @param string $lon Longitude
  165. * @param string $language ISO code for language for results
  166. * @param Location &$location Location object (may be null)
  167. *
  168. * @return boolean whether to continue (results in $location)
  169. */
  170. function onLocationFromLatLon($lat, $lon, $language, &$location)
  171. {
  172. // Make sure they're canonical
  173. $lat = $this->canonical($lat);
  174. $lon = $this->canonical($lon);
  175. $loc = $this->getCache(array('lat' => $lat,
  176. 'lon' => $lon));
  177. if ($loc !== false) {
  178. $location = $loc;
  179. return false;
  180. }
  181. try {
  182. $geonames = $this->getGeonames('findNearbyPlaceName',
  183. array('lat' => $lat,
  184. 'lng' => $lon,
  185. 'lang' => $language));
  186. } catch (Exception $e) {
  187. $this->log(LOG_WARNING, "Error for coords $lat, $lon: " . $e->getMessage());
  188. return true;
  189. }
  190. if (count($geonames) == 0) {
  191. // no results
  192. $this->setCache(array('lat' => $lat,
  193. 'lon' => $lon),
  194. null);
  195. return true;
  196. }
  197. $n = $geonames[0];
  198. $parts = array();
  199. $location = new Location();
  200. $parts[] = (string)$n->name;
  201. if (!empty($n->adminName1)) {
  202. $parts[] = (string)$n->adminName1;
  203. }
  204. if (!empty($n->countryName)) {
  205. $parts[] = (string)$n->countryName;
  206. }
  207. $location->location_id = (string)$n->geonameId;
  208. $location->location_ns = self::LOCATION_NS;
  209. $location->lat = $this->canonical($n->lat);
  210. $location->lon = $this->canonical($n->lng);
  211. $location->names[$language] = implode(', ', $parts);
  212. $this->setCache(array('lat' => $lat,
  213. 'lon' => $lon),
  214. $location);
  215. // Success! We handled it, so no further processing
  216. return false;
  217. }
  218. /**
  219. * Human-readable name for a location
  220. *
  221. * Given a location, we try to retrieve a human-readable name
  222. * in the target language.
  223. *
  224. * @param Location $location Location to get the name for
  225. * @param string $language ISO code for language to find name in
  226. * @param string &$name Place to put the name
  227. *
  228. * @return boolean whether to continue
  229. */
  230. function onLocationNameLanguage($location, $language, &$name)
  231. {
  232. if ($location->location_ns != self::LOCATION_NS) {
  233. // It's not one of our IDs... keep processing
  234. return true;
  235. }
  236. $id = $location->location_id;
  237. $n = $this->getCache(array('id' => $id,
  238. 'language' => $language));
  239. if ($n !== false) {
  240. $name = $n;
  241. return false;
  242. }
  243. try {
  244. $geonames = $this->getGeonames('hierarchy',
  245. array('geonameId' => $id,
  246. 'lang' => $language));
  247. } catch (Exception $e) {
  248. $this->log(LOG_WARNING, "Error for ID $id: " . $e->getMessage());
  249. return false;
  250. }
  251. if (count($geonames) == 0) {
  252. $this->setCache(array('id' => $id,
  253. 'language' => $language),
  254. null);
  255. return false;
  256. }
  257. $parts = array();
  258. foreach ($geonames as $level) {
  259. if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
  260. $parts[] = (string)$level->name;
  261. }
  262. }
  263. $last = $geonames[count($geonames)-1];
  264. if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
  265. $parts[] = (string)$last->name;
  266. }
  267. if (count($parts)) {
  268. $name = implode(', ', array_reverse($parts));
  269. $this->setCache(array('id' => $id,
  270. 'language' => $language),
  271. $name);
  272. }
  273. return false;
  274. }
  275. /**
  276. * Human-readable URL for a location
  277. *
  278. * Given a location, we try to retrieve a geonames.org URL.
  279. *
  280. * @param Location $location Location to get the url for
  281. * @param string &$url Place to put the url
  282. *
  283. * @return boolean whether to continue
  284. */
  285. function onLocationUrl($location, &$url)
  286. {
  287. if ($location->location_ns != self::LOCATION_NS) {
  288. // It's not one of our IDs... keep processing
  289. return true;
  290. }
  291. $url = 'http://www.geonames.org/' . $location->location_id;
  292. // it's been filled, so don't process further.
  293. return false;
  294. }
  295. /**
  296. * Machine-readable name for a location
  297. *
  298. * Given a location, we try to retrieve a geonames.org URL.
  299. *
  300. * @param Location $location Location to get the url for
  301. * @param string &$url Place to put the url
  302. *
  303. * @return boolean whether to continue
  304. */
  305. function onLocationRdfUrl($location, &$url)
  306. {
  307. if ($location->location_ns != self::LOCATION_NS) {
  308. // It's not one of our IDs... keep processing
  309. return true;
  310. }
  311. $url = 'http://sws.geonames.org/' . $location->location_id . '/';
  312. // it's been filled, so don't process further.
  313. return false;
  314. }
  315. function getCache($attrs)
  316. {
  317. $c = Cache::instance();
  318. if (empty($c)) {
  319. return null;
  320. }
  321. $key = $this->cacheKey($attrs);
  322. $value = $c->get($key);
  323. return $value;
  324. }
  325. function setCache($attrs, $loc)
  326. {
  327. $c = Cache::instance();
  328. if (empty($c)) {
  329. return null;
  330. }
  331. $key = $this->cacheKey($attrs);
  332. $result = $c->set($key, $loc, 0, time() + $this->expiry);
  333. return $result;
  334. }
  335. function cacheKey($attrs)
  336. {
  337. $key = 'geonames:' .
  338. implode(',', array_keys($attrs)) . ':'.
  339. Cache::keyize(implode(',', array_values($attrs)));
  340. if ($this->cachePrefix) {
  341. return $this->cachePrefix . ':' . $key;
  342. } else {
  343. return Cache::key($key);
  344. }
  345. }
  346. function wsUrl($method, $params)
  347. {
  348. if (!empty($this->username)) {
  349. $params['username'] = $this->username;
  350. }
  351. if (!empty($this->token)) {
  352. $params['token'] = $this->token;
  353. }
  354. $str = http_build_query($params, null, '&');
  355. return 'http://'.$this->host.'/'.$method.'?'.$str;
  356. }
  357. function getGeonames($method, $params)
  358. {
  359. if ($this->lastTimeout && (time() - $this->lastTimeout < $this->timeoutWindow)) {
  360. // TRANS: Exception thrown when a geo names service is not used because of a recent timeout.
  361. throw new Exception(_m('Skipping due to recent web service timeout.'));
  362. }
  363. $client = HTTPClient::start();
  364. $client->setConfig('connect_timeout', $this->timeout);
  365. $client->setConfig('timeout', $this->timeout);
  366. try {
  367. $result = $client->get($this->wsUrl($method, $params));
  368. } catch (Exception $e) {
  369. common_log(LOG_ERR, __METHOD__ . ": " . $e->getMessage());
  370. $this->lastTimeout = time();
  371. throw $e;
  372. }
  373. if (!$result->isOk()) {
  374. // TRANS: Exception thrown when a geo names service does not return an expected response.
  375. // TRANS: %s is an HTTP error code.
  376. throw new Exception(sprintf(_m('HTTP error code %s.'),$result->getStatus()));
  377. }
  378. $body = $result->getBody();
  379. if (empty($body)) {
  380. // TRANS: Exception thrown when a geo names service returns an empty body.
  381. throw new Exception(_m('Empty HTTP body in response.'));
  382. }
  383. // This will throw an exception if the XML is mal-formed
  384. $document = new SimpleXMLElement($body);
  385. // No children, usually no results
  386. $children = $document->children();
  387. if (count($children) == 0) {
  388. return array();
  389. }
  390. if (isset($document->status)) {
  391. // TRANS: Exception thrown when a geo names service return a specific error number and error text.
  392. // TRANS: %1$s is an error code, %2$s is an error message.
  393. throw new Exception(sprintf(_m('Error #%1$s ("%2$s").'),$document->status['value'],$document->status['message']));
  394. }
  395. // Array of elements, >0 elements
  396. return $document->geoname;
  397. }
  398. function onPluginVersion(array &$versions)
  399. {
  400. $versions[] = array('name' => 'Geonames',
  401. 'version' => GNUSOCIAL_VERSION,
  402. 'author' => 'Evan Prodromou',
  403. 'homepage' => 'http://status.net/wiki/Plugin:Geonames',
  404. 'rawdescription' =>
  405. // TRANS: Plugin description.
  406. _m('Uses <a href="http://geonames.org/">Geonames</a> service to get human-readable '.
  407. 'names for locations based on user-provided lat/long pairs.'));
  408. return true;
  409. }
  410. function canonical($coord)
  411. {
  412. $coord = rtrim($coord, "0");
  413. $coord = rtrim($coord, ".");
  414. return $coord;
  415. }
  416. }