A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

364 строки
17 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, config):
  49. """Set up the manager with an attribute for the BotConfig object."""
  50. self.config = config
  51. self._sitesdb = path.join(config.root_dir, "sites.db")
  52. self._cookie_file = path.join(config.root_dir, ".cookies")
  53. self._cookiejar = None
  54. def _get_cookiejar(self):
  55. """Return a LWPCookieJar object loaded from our .cookies file.
  56. The same .cookies file is returned every time, located in the project
  57. root, same directory as config.yml and bot.py. If it doesn't exist, we
  58. will create the file and set it to be readable and writeable only by
  59. us. If it exists but the information inside is bogus, we'll ignore it.
  60. This is normally called by _make_site_object() (in turn called by
  61. get_site()), and the cookiejar is passed to our Site's constructor,
  62. used when it makes API queries. This way, we can easily preserve
  63. cookies between sites (e.g., for CentralAuth), making logins easier.
  64. """
  65. if self._cookiejar:
  66. return self._cookiejar
  67. self._cookiejar = LWPCookieJar(self._cookie_file)
  68. try:
  69. self._cookiejar.load()
  70. except LoadError:
  71. pass # File contains bad data, so ignore it completely
  72. except IOError as e:
  73. if e.errno == errno.ENOENT: # "No such file or directory"
  74. # Create the file and restrict reading/writing only to the
  75. # owner, so others can't peak at our cookies:
  76. open(cookie_file, "w").close()
  77. chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR)
  78. else:
  79. raise
  80. return self._cookiejar
  81. def _create_sitesdb(self):
  82. """Initialize the sitesdb file with its three necessary tables."""
  83. script = """
  84. CREATE TABLE sites (site_name, site_project, site_lang, site_base_url,
  85. site_article_path, site_script_path);
  86. CREATE TABLE sql_data (sql_site, sql_data_key, sql_data_value);
  87. CREATE TABLE namespaces (ns_site, ns_id, ns_name, ns_is_primary_name);
  88. """
  89. with sqlite.connect(self._sitesdb) as conn:
  90. conn.executescript(script)
  91. def _load_site_from_sitesdb(self, name):
  92. """Return all information stored in the sitesdb relating to given site.
  93. The information will be returned as a tuple, containing the site's
  94. name, project, language, base URL, article path, script path, SQL
  95. connection data, and namespaces, in that order. If the site is not
  96. found in the database, SiteNotFoundError will be raised. An empty
  97. database will be created before the exception is raised if none exists.
  98. """
  99. query1 = "SELECT * FROM sites WHERE site_name = ?"
  100. query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?"
  101. query3 = "SELECT ns_id, ns_name, ns_is_primary_name FROM namespaces WHERE ns_site = ?"
  102. error = "Site '{0}' not found in the sitesdb.".format(name)
  103. with sqlite.connect(self._sitesdb) as conn:
  104. try:
  105. site_data = conn.execute(query1, (name,)).fetchone()
  106. except sqlite.OperationalError:
  107. self._create_sitesdb()
  108. raise SiteNotFoundError(error)
  109. if not site_data:
  110. raise SiteNotFoundError(error)
  111. sql_data = conn.execute(query2, (name,)).fetchall()
  112. ns_data = conn.execute(query3, (name,)).fetchall()
  113. name, project, lang, base_url, article_path, script_path = site_data
  114. sql = dict(sql_data)
  115. namespaces = {}
  116. for ns_id, ns_name, ns_is_primary_name in ns_data:
  117. try:
  118. if ns_is_primary_name: # "Primary" name goes first in list
  119. namespaces[ns_id].insert(0, ns_name)
  120. else: # Ordering of the aliases doesn't matter
  121. namespaces[ns_id].append(ns_name)
  122. except KeyError:
  123. namespaces[ns_id] = [ns_name]
  124. return (name, project, lang, base_url, article_path, script_path, sql,
  125. namespaces)
  126. def _make_site_object(self, name):
  127. """Return a Site object associated with the site 'name' in our sitesdb.
  128. This calls _load_site_from_sitesdb(), so SiteNotFoundError will be
  129. raised if the site is not in our sitesdb.
  130. """
  131. cookiejar = self._get_cookiejar()
  132. (name, project, lang, base_url, article_path, script_path, sql,
  133. namespaces) = self._load_site_from_sitesdb(name)
  134. config = self.config
  135. login = (config.wiki.get("username"), config.wiki.get("password"))
  136. user_agent = config.wiki.get("userAgent")
  137. use_https = config.wiki.get("useHTTPS", False)
  138. assert_edit = config.wiki.get("assert")
  139. maxlag = config.wiki.get("maxlag")
  140. search_config = config.wiki.get("search")
  141. if user_agent:
  142. user_agent = user_agent.replace("$1", __version__)
  143. user_agent = user_agent.replace("$2", python_version())
  144. return Site(name=name, project=project, lang=lang, base_url=base_url,
  145. article_path=article_path, script_path=script_path,
  146. sql=sql, namespaces=namespaces, login=login,
  147. cookiejar=cookiejar, user_agent=user_agent,
  148. use_https=use_https, assert_edit=assert_edit,
  149. maxlag=maxlag, search_config=search_config)
  150. def _get_site_name_from_sitesdb(self, project, lang):
  151. """Return the name of the first site with the given project and lang.
  152. If the site is not found, return None. An empty sitesdb will be created
  153. if none exists.
  154. """
  155. query = "SELECT site_name FROM sites WHERE site_project = ? and site_lang = ?"
  156. with sqlite.connect(self._sitesdb) as conn:
  157. try:
  158. site = conn.execute(query, (project, lang)).fetchone()
  159. return site[0] if site else None
  160. except sqlite.OperationalError:
  161. self._create_sitesdb()
  162. def _add_site_to_sitesdb(self, site):
  163. """Extract relevant info from a Site object and add it to the sitesdb.
  164. Works like a reverse _load_site_from_sitesdb(); the site's project,
  165. language, base URL, article path, script path, SQL connection data, and
  166. namespaces are extracted from the site and inserted into the sites
  167. database. If the sitesdb doesn't exist, we'll create it first.
  168. """
  169. name = site.name()
  170. sites_data = (name, site.project(), site.lang(), site._base_url,
  171. site._article_path, site._script_path)
  172. sql_data = [(name, key, val) for key, val in site._sql_data.iteritems()]
  173. ns_data = []
  174. for ns_id, ns_names in site._namespaces.iteritems():
  175. ns_data.append((name, ns_id, ns_names.pop(0), True))
  176. for ns_name in ns_names:
  177. ns_data.append((name, ns_id, ns_name, False))
  178. with sqlite.connect(self._sitesdb) as conn:
  179. check_exists = "SELECT 1 FROM sites WHERE site_name = ?"
  180. try:
  181. exists = conn.execute(check_exists, (name,)).fetchone()
  182. except sqlite.OperationalError:
  183. self._create_sitesdb()
  184. else:
  185. if exists:
  186. conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
  187. conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
  188. conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
  189. conn.execute("INSERT INTO sites VALUES (?, ?, ?, ?, ?, ?)", sites_data)
  190. conn.executemany("INSERT INTO sql_data VALUES (?, ?, ?)", sql_data)
  191. conn.executemany("INSERT INTO namespaces VALUES (?, ?, ?, ?)", ns_data)
  192. def _remove_site_from_sitesdb(self, name):
  193. """Remove a site by name from the sitesdb."""
  194. with sqlite.connect(self._sitesdb) as conn:
  195. cursor = conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
  196. if cursor.rowcount == 0:
  197. return False
  198. else:
  199. conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
  200. conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
  201. return True
  202. def get_site(self, name=None, project=None, lang=None):
  203. """Return a Site instance based on information from the sitesdb.
  204. With no arguments, return the default site as specified by our config
  205. file. This is config.wiki["defaultSite"].
  206. With 'name' specified, return the site with that name. This is
  207. equivalent to the site's 'wikiid' in the API, like 'enwiki'.
  208. With 'project' and 'lang' specified, return the site whose project and
  209. language match these values. If there are multiple sites with the same
  210. values (unlikely), this is not a reliable way of loading a site. Call
  211. the function with an explicit 'name' in that case.
  212. We will attempt to login to the site automatically using
  213. config.wiki["username"] and config.wiki["password"] if both are
  214. defined.
  215. Specifying a project without a lang or a lang without a project will
  216. raise TypeError. If all three args are specified, 'name' will be first
  217. tried, then 'project' and 'lang' if 'name' doesn't work. If a site
  218. cannot be found in the sitesdb, SiteNotFoundError will be raised. An
  219. empty sitesdb will be created if none is found.
  220. """
  221. # Someone specified a project without a lang, or vice versa:
  222. if (project and not lang) or (not project and lang):
  223. e = "Keyword arguments 'lang' and 'project' must be specified together."
  224. raise TypeError(e)
  225. # No args given, so return our default site:
  226. if not name and not project and not lang:
  227. try:
  228. default = self.config.wiki["defaultSite"]
  229. except KeyError:
  230. e = "Default site is not specified in config."
  231. raise SiteNotFoundError(e)
  232. return self._make_site_object(default)
  233. # Name arg given, but don't look at others unless `name` isn't found:
  234. if name:
  235. try:
  236. return self._make_site_object(name)
  237. except SiteNotFoundError:
  238. if project and lang:
  239. name = self._get_site_name_from_sitesdb(project, lang)
  240. if name:
  241. return self._make_site_object(name)
  242. raise
  243. # If we end up here, then project and lang are the only args given:
  244. name = self._get_site_name_from_sitesdb(project, lang)
  245. if name:
  246. return self._make_site_object(name)
  247. e = "Site '{0}:{1}' not found in the sitesdb.".format(project, lang)
  248. raise SiteNotFoundError(e)
  249. def add_site(self, project=None, lang=None, base_url=None,
  250. script_path="/w", sql=None):
  251. """Add a site to the sitesdb so it can be retrieved with get_site().
  252. If only a project and a lang are given, we'll guess the base_url as
  253. "//{lang}.{project}.org" (which is protocol-relative, becoming 'https'
  254. if 'useHTTPS' is True in config otherwise 'http'). If this is wrong,
  255. provide the correct base_url as an argument (in which case project and
  256. lang are ignored). Most wikis use "/w" as the script path (meaning the
  257. API is located at "{base_url}{script_path}/api.php" ->
  258. "//{lang}.{project}.org/w/api.php"), so this is the default. If your
  259. wiki is different, provide the script_path as an argument. The only
  260. other argument to Site() that we can't get from config files or by
  261. querying the wiki itself is SQL connection info, so provide a dict of
  262. kwargs as `sql` and Site will pass it to oursql.connect(**sql),
  263. allowing you to make queries with site.sql_query().
  264. Returns True if the site was added successfully or False if the site is
  265. already in our sitesdb (this can be done purposefully to update old
  266. site info). Raises SiteNotFoundError if not enough information has
  267. been provided to identify the site (e.g. a project but not a lang).
  268. """
  269. if not base_url:
  270. if not project or not lang:
  271. e = "Without a base_url, both a project and a lang must be given."
  272. raise SiteNotFoundError(e)
  273. base_url = "//{0}.{1}.org".format(lang, project)
  274. cookiejar = self._get_cookiejar()
  275. config = self.config
  276. login = (config.wiki.get("username"), config.wiki.get("password"))
  277. user_agent = config.wiki.get("userAgent")
  278. use_https = config.wiki.get("useHTTPS", False)
  279. assert_edit = config.wiki.get("assert")
  280. maxlag = config.wiki.get("maxlag")
  281. search_config = config.wiki.get("search")
  282. # Create a temp Site object to log in and load the other attributes:
  283. site = Site(base_url=base_url, script_path=script_path, sql=sql,
  284. login=login, cookiejar=cookiejar, user_agent=user_agent,
  285. use_https=use_https, assert_edit=assert_edit,
  286. maxlag=maxlag, search_config=search_config)
  287. self._add_site_to_sitesdb(site)
  288. return site
  289. def remove_site(self, name=None, project=None, lang=None):
  290. """Remove a site from the sitesdb.
  291. Returns True if the site was removed successfully or False if the site
  292. was not in our sitesdb originally. If all three args (name, project,
  293. and lang) are given, we'll first try 'name' and then try the latter two
  294. if 'name' wasn't found in the database. Raises TypeError if a project
  295. was given but not a language, or vice versa. Will create an empty
  296. sitesdb if none was found.
  297. """
  298. # Someone specified a project without a lang, or vice versa:
  299. if (project and not lang) or (not project and lang):
  300. e = "Keyword arguments 'lang' and 'project' must be specified together."
  301. raise TypeError(e)
  302. if name:
  303. was_removed = self._remove_site_from_sitesdb(name)
  304. if not was_removed:
  305. if project and lang:
  306. name = self._get_site_name_from_sitesdb(project, lang)
  307. if name:
  308. return self._remove_site_from_sitesdb(name)
  309. return was_removed
  310. if project and lang:
  311. name = self._get_site_name_from_sitesdb(project, lang)
  312. if name:
  313. return self._remove_site_from_sitesdb(name)
  314. return False