A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

189 regels
7.1 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2012 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 os import expanduser
  23. import re
  24. from threading import RLock
  25. from time import sleep, time
  26. import oursql
  27. from earwigbot.tasks import Task
  28. class DRNClerkBot(Task):
  29. """A task to clerk for [[WP:DRN]]."""
  30. name = "drn_clerkbot"
  31. number = 19
  32. # Case status:
  33. STATUS_UNKNOWN = 0
  34. STATUS_NEW = 1
  35. STATUS_OPEN = 2
  36. STATUS_STALE = 3
  37. STATUS_NEEDASSIST = 4
  38. STATUS_REVIEW = 5
  39. STATUS_RESOLVED = 6
  40. STATUS_CLOSED = 7
  41. STATUS_ARCHIVE = 8
  42. def setup(self):
  43. """Hook called immediately after the task is loaded."""
  44. cfg = self.config.tasks.get(self.name, {})
  45. # Set some wiki-related attributes:
  46. self.title = cfg.get("page", "Wikipedia:Dispute resolution noticeboard")
  47. default_summary = "Updating $3 cases for the [[WP:DRN|dispute resolution noticeboard]]."
  48. self.summary = self.make_summary(cfg.get("summary", default_summary))
  49. # Templates used:
  50. templates = cfg.get("templates", {})
  51. self.tl_status = templates.get("status", "DR case status")
  52. self.tl_notify_party = templates.get("notifyParty", "DRN-notice")
  53. self.tl_notify_stale = templates.get("notifyStale", "DRN stale notice")
  54. self.tl_archive_top = templates.get("archiveTop", "DRN archive top")
  55. self.tl_archive_bottom = templates.get("archiveBottom",
  56. "DRN archive bottom")
  57. # Connection data for our SQL database:
  58. kwargs = cfg.get("sql", {})
  59. kwargs["read_default_file"] = expanduser("~/.my.cnf")
  60. self.conn_data = kwargs
  61. self.db_access_lock = RLock()
  62. def run(self, **kwargs):
  63. """Entry point for a task event."""
  64. if not self.db_access_lock.acquire(False): # Non-blocking
  65. self.logger.info("A job is already ongoing; aborting")
  66. return
  67. with self.db_access_lock:
  68. self.logger.info(u"Starting update to [[{0}]]".format(self.title))
  69. start = time()
  70. conn = oursql.connect(**self.conn_data)
  71. cases = read_database(conn)
  72. page = self.bot.wiki.get_site().get_page(self.title)
  73. text = page.get()
  74. read_page(conn, cases, text)
  75. # Work!
  76. # Send messages!
  77. self.save(page, cases)
  78. def save(self, page, cases):
  79. newtext = text = page.get()
  80. counter = 0
  81. for case in cases:
  82. if case.old != case.body:
  83. newtext = newtext.replace(case.old, case.body)
  84. counter += 1
  85. worktime = time() - start
  86. if worktime < 60:
  87. sleep(60 - worktime)
  88. page.reload()
  89. if page.get() != text:
  90. log = "Someone has edited the page while we were working; restarting"
  91. self.logger.warn(log)
  92. return self.run()
  93. summary = self.summary.replace("$3", str(counter))
  94. page.edit(text, summary, minor=False, bot=True)
  95. self.logger.info(u"Saved page [[{0}]]".format(page.title))
  96. def read_database(self, conn):
  97. """Return a list of _Cases from the database."""
  98. cases = []
  99. query = "SELECT case_id, case_title, case_status FROM case"
  100. with conn.cursor() as cursor:
  101. cursor.execute(query)
  102. for id_, name, status in cursor:
  103. cases.append(_Case(id_, title, status))
  104. return cases
  105. def read_page(self, conn, cases, text):
  106. """Read the noticeboard content and update the list of _Cases."""
  107. tl_status_esc = re.escape(self.tl_status)
  108. split = re.split("(^==\s*[^=]+?\s*==$)", text, flags=re.M|re.U)
  109. for i in xrange(len(split)):
  110. if i + 1 == len(split):
  111. break
  112. if not split[i].startswith("=="):
  113. continue
  114. title = split[i][2:-2].strip()
  115. body = old = split[i + 1]
  116. if not re.search("\s*\{\{" + tl_status_esc, body, re.U):
  117. continue
  118. status = self.read_status(body)
  119. re_id = "<!-- EarwigBot Case ID \(please don't modify me\): (.*?) -->"
  120. try:
  121. id_ = re.search(re_id, body).group(1)
  122. case = [case for case in cases if case.id == id_][0]
  123. except (AttributeError, IndexError):
  124. id_ = self.select_next_id(conn)
  125. re_id2 = "(\{\{" + tl_status_esc + "(.*?)\}\})(<!-- Case ID \(please don't modify\): .*? -->)?"
  126. repl = ur"\1 <!-- Case ID (please don't modify): {0} -->"
  127. body = re.sub(re_id2, repl.format(id_), body)
  128. case = _Case(id_, title, status)
  129. cases.append(case)
  130. case.body, case.old = body, old
  131. def select_next_id(self, conn):
  132. """Return the next incremental ID for a case."""
  133. query = "SELECT MAX(case_id) FROM case"
  134. with conn.cursor() as cursor:
  135. cursor.execute(query)
  136. current = cursor.fetchone()[0]
  137. if current:
  138. return current + 1
  139. return 1
  140. def read_status(self, body):
  141. """Parse the current status from a case body."""
  142. aliases = {
  143. self.STATUS_NEW: ("",),
  144. self.STATUS_OPEN: ("open", "active", "inprogress"),
  145. self.STATUS_STALE: ("stale",),
  146. self.STATUS_NEEDASSIST: ("needassist", "relist", "relisted"),
  147. self.STATUS_REVIEW: ("review",),
  148. self.STATUS_RESOLVED: ("resolved", "resolve"),
  149. self.STATUS_CLOSED: ("closed", "close"),
  150. }
  151. templ = re.escape(self.tl_status)
  152. status = re.search("\{\{" + templ + "\|?(.*?)\}\}", body, re.S|re.U)
  153. if not status:
  154. return self.STATUS_UNKNOWN
  155. for option, names in aliases.iteritems():
  156. if status.group(1).lower() in names:
  157. return option
  158. return self.STATUS_UNKNOWN
  159. class _Case(object):
  160. """A simple object representing a dispute resolution case."""
  161. def __init__(self, id_, title, status):
  162. self.id = id_
  163. self.title = title
  164. self.status = status
  165. self.body = None
  166. self.old = None