GeonamesPlugin.php 16 KB

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