A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

391 satır
18 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in
  13. # all copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. from cookielib import LWPCookieJar, LoadError
  23. import errno
  24. from getpass import getpass
  25. from os import chmod, path
  26. from platform import python_version
  27. import stat
  28. import sqlite3 as sqlite
  29. from earwigbot import __version__
  30. from earwigbot.wiki.exceptions import SiteNotFoundError
  31. from earwigbot.wiki.site import Site
  32. __all__ = ["SitesDB"]
  33. class SitesDB(object):
  34. """
  35. EarwigBot's Wiki Toolset: Sites Database Manager
  36. This class controls the sites.db file, which stores information about all
  37. wiki sites known to the bot. Three public methods act as bridges between
  38. the bot's config files and Site objects:
  39. get_site -- returns a Site object corresponding to a given site name
  40. add_site -- stores a site in the database, given connection info
  41. remove_site -- removes a site from the database, given its name
  42. There's usually no need to use this class directly. All public methods
  43. here are available as bot.wiki.get_site(), bot.wiki.add_site(), and
  44. bot.wiki.remove_site(), which use a sites.db file located in the same
  45. directory as our config.yml file. Lower-level access can be achieved
  46. by importing the manager class (`from earwigbot.wiki import SitesDB`).
  47. """
  48. def __init__(self, bot):
  49. """Set up the manager with an attribute for the base Bot object."""
  50. self.config = bot.config
  51. self._logger = bot.logger.getChild("wiki")
  52. self._sites = {} # Internal site cache
  53. self._sitesdb = path.join(config.root_dir, "sites.db")
  54. self._cookie_file = path.join(config.root_dir, ".cookies")
  55. self._cookiejar = None
  56. def _get_cookiejar(self):
  57. """Return a LWPCookieJar object loaded from our .cookies file.
  58. The same .cookies file is returned every time, located in the project
  59. root, same directory as config.yml and bot.py. If it doesn't exist, we
  60. will create the file and set it to be readable and writeable only by
  61. us. If it exists but the information inside is bogus, we'll ignore it.
  62. This is normally called by _make_site_object() (in turn called by
  63. get_site()), and the cookiejar is passed to our Site's constructor,
  64. used when it makes API queries. This way, we can easily preserve
  65. cookies between sites (e.g., for CentralAuth), making logins easier.
  66. """
  67. if self._cookiejar:
  68. return self._cookiejar
  69. self._cookiejar = LWPCookieJar(self._cookie_file)
  70. try:
  71. self._cookiejar.load()
  72. except LoadError:
  73. pass # File contains bad data, so ignore it completely
  74. except IOError as e:
  75. if e.errno == errno.ENOENT: # "No such file or directory"
  76. # Create the file and restrict reading/writing only to the
  77. # owner, so others can't peak at our cookies:
  78. open(cookie_file, "w").close()
  79. chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR)
  80. else:
  81. raise
  82. return self._cookiejar
  83. def _create_sitesdb(self):
  84. """Initialize the sitesdb file with its three necessary tables."""
  85. script = """
  86. CREATE TABLE sites (site_name, site_project, site_lang, site_base_url,
  87. site_article_path, site_script_path);
  88. CREATE TABLE sql_data (sql_site, sql_data_key, sql_data_value);
  89. CREATE TABLE namespaces (ns_site, ns_id, ns_name, ns_is_primary_name);
  90. """
  91. with sqlite.connect(self._sitesdb) as conn:
  92. conn.executescript(script)
  93. def _get_site_object(self, name):
  94. """Return the site from our cache, or create it if it doesn't exist.
  95. This is essentially just a wrapper around _make_site_object that
  96. returns the same object each time a specific site is asked for.
  97. """
  98. try:
  99. return self._sites[name]
  100. except KeyError:
  101. site = self._make_site_object(name)
  102. self._sites[name] = site
  103. return site
  104. def _load_site_from_sitesdb(self, name):
  105. """Return all information stored in the sitesdb relating to given site.
  106. The information will be returned as a tuple, containing the site's
  107. name, project, language, base URL, article path, script path, SQL
  108. connection data, and namespaces, in that order. If the site is not
  109. found in the database, SiteNotFoundError will be raised. An empty
  110. database will be created before the exception is raised if none exists.
  111. """
  112. query1 = "SELECT * FROM sites WHERE site_name = ?"
  113. query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?"
  114. query3 = "SELECT ns_id, ns_name, ns_is_primary_name FROM namespaces WHERE ns_site = ?"
  115. error = "Site '{0}' not found in the sitesdb.".format(name)
  116. with sqlite.connect(self._sitesdb) as conn:
  117. try:
  118. site_data = conn.execute(query1, (name,)).fetchone()
  119. except sqlite.OperationalError:
  120. self._create_sitesdb()
  121. raise SiteNotFoundError(error)
  122. if not site_data:
  123. raise SiteNotFoundError(error)
  124. sql_data = conn.execute(query2, (name,)).fetchall()
  125. ns_data = conn.execute(query3, (name,)).fetchall()
  126. name, project, lang, base_url, article_path, script_path = site_data
  127. sql = dict(sql_data)
  128. namespaces = {}
  129. for ns_id, ns_name, ns_is_primary_name in ns_data:
  130. try:
  131. if ns_is_primary_name: # "Primary" name goes first in list
  132. namespaces[ns_id].insert(0, ns_name)
  133. else: # Ordering of the aliases doesn't matter
  134. namespaces[ns_id].append(ns_name)
  135. except KeyError:
  136. namespaces[ns_id] = [ns_name]
  137. return (name, project, lang, base_url, article_path, script_path, sql,
  138. namespaces)
  139. def _make_site_object(self, name):
  140. """Return a Site object associated with the site 'name' in our sitesdb.
  141. This calls _load_site_from_sitesdb(), so SiteNotFoundError will be
  142. raised if the site is not in our sitesdb.
  143. """
  144. cookiejar = self._get_cookiejar()
  145. (name, project, lang, base_url, article_path, script_path, sql,
  146. namespaces) = self._load_site_from_sitesdb(name)
  147. config = self.config
  148. login = (config.wiki.get("username"), config.wiki.get("password"))
  149. user_agent = config.wiki.get("userAgent")
  150. use_https = config.wiki.get("useHTTPS", False)
  151. assert_edit = config.wiki.get("assert")
  152. maxlag = config.wiki.get("maxlag")
  153. wait_between_queries = config.wiki.get("waitTime", 5)
  154. logger = self._logger.getChild(name)
  155. search_config = config.wiki.get("search")
  156. if user_agent:
  157. user_agent = user_agent.replace("$1", __version__)
  158. user_agent = user_agent.replace("$2", python_version())
  159. return Site(name=name, project=project, lang=lang, base_url=base_url,
  160. article_path=article_path, script_path=script_path,
  161. sql=sql, namespaces=namespaces, login=login,
  162. cookiejar=cookiejar, user_agent=user_agent,
  163. use_https=use_https, assert_edit=assert_edit,
  164. maxlag=maxlag, wait_between_queries=wait_between_queries,
  165. logger=logger, search_config=search_config)
  166. def _get_site_name_from_sitesdb(self, project, lang):
  167. """Return the name of the first site with the given project and lang.
  168. If the site is not found, return None. An empty sitesdb will be created
  169. if none exists.
  170. """
  171. query = "SELECT site_name FROM sites WHERE site_project = ? and site_lang = ?"
  172. with sqlite.connect(self._sitesdb) as conn:
  173. try:
  174. site = conn.execute(query, (project, lang)).fetchone()
  175. return site[0] if site else None
  176. except sqlite.OperationalError:
  177. self._create_sitesdb()
  178. def _add_site_to_sitesdb(self, site):
  179. """Extract relevant info from a Site object and add it to the sitesdb.
  180. Works like a reverse _load_site_from_sitesdb(); the site's project,
  181. language, base URL, article path, script path, SQL connection data, and
  182. namespaces are extracted from the site and inserted into the sites
  183. database. If the sitesdb doesn't exist, we'll create it first.
  184. """
  185. name = site.name()
  186. sites_data = (name, site.project(), site.lang(), site._base_url,
  187. site._article_path, site._script_path)
  188. sql_data = [(name, key, val) for key, val in site._sql_data.iteritems()]
  189. ns_data = []
  190. for ns_id, ns_names in site._namespaces.iteritems():
  191. ns_data.append((name, ns_id, ns_names.pop(0), True))
  192. for ns_name in ns_names:
  193. ns_data.append((name, ns_id, ns_name, False))
  194. with sqlite.connect(self._sitesdb) as conn:
  195. check_exists = "SELECT 1 FROM sites WHERE site_name = ?"
  196. try:
  197. exists = conn.execute(check_exists, (name,)).fetchone()
  198. except sqlite.OperationalError:
  199. self._create_sitesdb()
  200. else:
  201. if exists:
  202. conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
  203. conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
  204. conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
  205. conn.execute("INSERT INTO sites VALUES (?, ?, ?, ?, ?, ?)", sites_data)
  206. conn.executemany("INSERT INTO sql_data VALUES (?, ?, ?)", sql_data)
  207. conn.executemany("INSERT INTO namespaces VALUES (?, ?, ?, ?)", ns_data)
  208. def _remove_site_from_sitesdb(self, name):
  209. """Remove a site by name from the sitesdb and the internal cache."""
  210. try:
  211. del self._sites[name]
  212. except KeyError:
  213. pass
  214. with sqlite.connect(self._sitesdb) as conn:
  215. cursor = conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
  216. if cursor.rowcount == 0:
  217. return False
  218. else:
  219. conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
  220. conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
  221. return True
  222. def get_site(self, name=None, project=None, lang=None):
  223. """Return a Site instance based on information from the sitesdb.
  224. With no arguments, return the default site as specified by our config
  225. file. This is config.wiki["defaultSite"].
  226. With 'name' specified, return the site with that name. This is
  227. equivalent to the site's 'wikiid' in the API, like 'enwiki'.
  228. With 'project' and 'lang' specified, return the site whose project and
  229. language match these values. If there are multiple sites with the same
  230. values (unlikely), this is not a reliable way of loading a site. Call
  231. the function with an explicit 'name' in that case.
  232. We will attempt to login to the site automatically using
  233. config.wiki["username"] and config.wiki["password"] if both are
  234. defined.
  235. Specifying a project without a lang or a lang without a project will
  236. raise TypeError. If all three args are specified, 'name' will be first
  237. tried, then 'project' and 'lang' if 'name' doesn't work. If a site
  238. cannot be found in the sitesdb, SiteNotFoundError will be raised. An
  239. empty sitesdb will be created if none is found.
  240. """
  241. # Someone specified a project without a lang, or vice versa:
  242. if (project and not lang) or (not project and lang):
  243. e = "Keyword arguments 'lang' and 'project' must be specified together."
  244. raise TypeError(e)
  245. # No args given, so return our default site:
  246. if not name and not project and not lang:
  247. try:
  248. default = self.config.wiki["defaultSite"]
  249. except KeyError:
  250. e = "Default site is not specified in config."
  251. raise SiteNotFoundError(e)
  252. return self._get_site_object(default)
  253. # Name arg given, but don't look at others unless `name` isn't found:
  254. if name:
  255. try:
  256. return self._get_site_object(name)
  257. except SiteNotFoundError:
  258. if project and lang:
  259. name = self._get_site_name_from_sitesdb(project, lang)
  260. if name:
  261. return self._get_site_object(name)
  262. raise
  263. # If we end up here, then project and lang are the only args given:
  264. name = self._get_site_name_from_sitesdb(project, lang)
  265. if name:
  266. return self._get_site_object(name)
  267. e = "Site '{0}:{1}' not found in the sitesdb.".format(project, lang)
  268. raise SiteNotFoundError(e)
  269. def add_site(self, project=None, lang=None, base_url=None,
  270. script_path="/w", sql=None):
  271. """Add a site to the sitesdb so it can be retrieved with get_site().
  272. If only a project and a lang are given, we'll guess the base_url as
  273. "//{lang}.{project}.org" (which is protocol-relative, becoming 'https'
  274. if 'useHTTPS' is True in config otherwise 'http'). If this is wrong,
  275. provide the correct base_url as an argument (in which case project and
  276. lang are ignored). Most wikis use "/w" as the script path (meaning the
  277. API is located at "{base_url}{script_path}/api.php" ->
  278. "//{lang}.{project}.org/w/api.php"), so this is the default. If your
  279. wiki is different, provide the script_path as an argument. The only
  280. other argument to Site() that we can't get from config files or by
  281. querying the wiki itself is SQL connection info, so provide a dict of
  282. kwargs as `sql` and Site will pass it to oursql.connect(**sql),
  283. allowing you to make queries with site.sql_query().
  284. Returns True if the site was added successfully or False if the site is
  285. already in our sitesdb (this can be done purposefully to update old
  286. site info). Raises SiteNotFoundError if not enough information has
  287. been provided to identify the site (e.g. a project but not a lang).
  288. """
  289. if not base_url:
  290. if not project or not lang:
  291. e = "Without a base_url, both a project and a lang must be given."
  292. raise SiteNotFoundError(e)
  293. base_url = "//{0}.{1}.org".format(lang, project)
  294. cookiejar = self._get_cookiejar()
  295. config = self.config
  296. login = (config.wiki.get("username"), config.wiki.get("password"))
  297. user_agent = config.wiki.get("userAgent")
  298. use_https = config.wiki.get("useHTTPS", False)
  299. assert_edit = config.wiki.get("assert")
  300. maxlag = config.wiki.get("maxlag")
  301. wait_between_queries = config.wiki.get("waitTime", 5)
  302. logger = self._logger.getChild(name)
  303. search_config = config.wiki.get("search")
  304. # Create a Site object to log in and load the other attributes:
  305. site = Site(base_url=base_url, script_path=script_path, sql=sql,
  306. login=login, cookiejar=cookiejar, user_agent=user_agent,
  307. use_https=use_https, assert_edit=assert_edit,
  308. maxlag=maxlag, wait_between_queries=wait_between_queries,
  309. logger=logger, search_config=search_config)
  310. self._add_site_to_sitesdb(site)
  311. self._sites[site.name()] = site
  312. return site
  313. def remove_site(self, name=None, project=None, lang=None):
  314. """Remove a site from the sitesdb.
  315. Returns True if the site was removed successfully or False if the site
  316. was not in our sitesdb originally. If all three args (name, project,
  317. and lang) are given, we'll first try 'name' and then try the latter two
  318. if 'name' wasn't found in the database. Raises TypeError if a project
  319. was given but not a language, or vice versa. Will create an empty
  320. sitesdb if none was found.
  321. """
  322. # Someone specified a project without a lang, or vice versa:
  323. if (project and not lang) or (not project and lang):
  324. e = "Keyword arguments 'lang' and 'project' must be specified together."
  325. raise TypeError(e)
  326. if name:
  327. was_removed = self._remove_site_from_sitesdb(name)
  328. if not was_removed:
  329. if project and lang:
  330. name = self._get_site_name_from_sitesdb(project, lang)
  331. if name:
  332. return self._remove_site_from_sitesdb(name)
  333. return was_removed
  334. if project and lang:
  335. name = self._get_site_name_from_sitesdb(project, lang)
  336. if name:
  337. return self._remove_site_from_sitesdb(name)
  338. return False