GeonamesPlugin.php 16 KB

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