A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

402 lignes
17 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2014 Ben Kurtovic <ben.kurtovic@gmail.com>
  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 collections import namedtuple
  23. from gzip import GzipFile
  24. from math import log
  25. from Queue import Empty, Queue
  26. from socket import timeout
  27. from StringIO import StringIO
  28. from threading import Lock, Semaphore, Thread
  29. from time import sleep, time
  30. from urllib2 import build_opener, URLError
  31. from earwigbot import exceptions, importer
  32. from earwigbot.wiki.copyvios.markov import (
  33. EMPTY, EMPTY_INTERSECTION, MarkovChain, MarkovChainIntersection)
  34. from earwigbot.wiki.copyvios.parsers import ArticleTextParser, HTMLTextParser
  35. from earwigbot.wiki.copyvios.result import CopyvioCheckResult
  36. from earwigbot.wiki.copyvios.search import YahooBOSSSearchEngine
  37. oauth = importer.new("oauth2")
  38. tldextract = importer.new("tldextract")
  39. __all__ = ["CopyvioMixIn"]
  40. _WorkingResult = namedtuple("_WorkingResult", ["url", "confidence", "chains"])
  41. class _CopyvioWorkspace(object):
  42. """Manages a single copyvio check distributed across threads."""
  43. def __init__(self, article, min_confidence, until, logger, headers,
  44. url_timeout=5, max_concurrent_requests=6):
  45. self.best = _WorkingResult(None, 0.0, (EMPTY, EMPTY_INTERSECTION))
  46. self.request_semaphore = Semaphore(max_concurrent_requests)
  47. self._article = article
  48. self._logger = logger.getChild("copyvios")
  49. self._min_confidence = min_confidence
  50. self._handled_urls = []
  51. self._is_finished = False
  52. self._enqueue_lock = Lock()
  53. self._result_lock = Lock()
  54. self._workers = {}
  55. self._worker_args = (self, until, headers, url_timeout)
  56. def _calculate_confidence(self, delta):
  57. """Return the confidence of a violation as a float between 0 and 1."""
  58. def conf_with_article_and_delta(article, delta):
  59. """Calculate confidence using the article and delta chain sizes."""
  60. # This piecewise function, C_AΔ(Δ), was defined such that
  61. # confidence exhibits exponential growth until it reaches the
  62. # default "suspect" confidence threshold, at which point it
  63. # transitions to polynomial growth with lim (A/Δ)→1 C_AΔ(A,Δ) = 1.
  64. # A graph can be viewed here:
  65. # http://benkurtovic.com/static/article-delta_confidence_function.pdf
  66. ratio = delta / article
  67. if ratio <= 0.52763:
  68. return log(1 / (1 - ratio))
  69. else:
  70. return (-0.8939 * (ratio ** 2)) + (1.8948 * ratio) - 0.0009
  71. def conf_with_delta(delta):
  72. """Calculate confidence using just the delta chain size."""
  73. # This piecewise function, C_Δ(Δ), was derived from experimental
  74. # data using reference points at (0, 0), (100, 0.5), (250, 0.75),
  75. # (500, 0.9), and (1000, 0.95) with lim Δ→+∞ C_Δ(Δ) = 1.
  76. # A graph can be viewed here:
  77. # http://benkurtovic.com/static/delta_confidence_function.pdf
  78. if delta <= 100:
  79. return delta / (delta + 100)
  80. elif delta <= 250:
  81. return (delta - 25) / (delta + 50)
  82. elif delta <= 500:
  83. return (10.5 * delta - 750) / (10 * delta)
  84. else:
  85. return (delta - 50) / delta
  86. d_size = float(delta.size)
  87. return max(conf_with_article_and_delta(self._article.size, d_size),
  88. conf_with_delta(d_size))
  89. def _finish_early(self):
  90. """Finish handling links prematurely (if we've hit min_confidence)."""
  91. self._logger.debug("Confidence threshold met; clearing worker queues")
  92. with self._enqueue_lock:
  93. for worker in self._workers.itervalues():
  94. with worker.queue.mutex:
  95. worker.queue.queue.clear()
  96. worker.queue.queue.append(None)
  97. self._is_finished = True
  98. def enqueue(self, urls, exclude_check=None):
  99. """Put a list of URLs into the worker queue.
  100. *exclude_check* is an optional exclusion function that takes a URL and
  101. returns ``True`` if we should skip it and ``False`` otherwise.
  102. """
  103. for url in urls:
  104. with self._enqueue_lock:
  105. if self._is_finished:
  106. break
  107. if url in self._handled_urls:
  108. continue
  109. self._handled_urls.append(url)
  110. if exclude_check and exclude_check(url):
  111. continue
  112. try:
  113. key = tldextract.extract(url).registered_domain
  114. except ImportError: # Fall back on very naive method
  115. from urlparse import urlparse
  116. key = u".".join(urlparse(url).netloc.split(".")[-2:])
  117. logmsg = u"enqueue(): {0} {1} -> {2}"
  118. if key in self._workers:
  119. self._logger.debug(logmsg.format("PUT", key, url))
  120. self._workers[key].queue.put(url)
  121. else:
  122. self._logger.debug(logmsg.format("NEW", key, url))
  123. worker = _CopyvioWorker(*self._worker_args)
  124. worker.queue.put(url)
  125. worker.start(key.encode("utf8"))
  126. self._workers[key] = worker
  127. def wait(self):
  128. """Wait for the workers to finish handling the queue."""
  129. self._logger.debug("Waiting on {0} workers".format(len(self._workers)))
  130. for worker in self._workers.itervalues():
  131. worker.queue.put(None) # Exit signal to workers
  132. for worker in self._workers.itervalues():
  133. worker.join()
  134. def compare(self, url, source):
  135. """Compare a source to the article, and update the working result."""
  136. delta = MarkovChainIntersection(self._article, source)
  137. confidence = self._calculate_confidence(delta)
  138. self._logger.debug(u"compare(): {0} -> {1}".format(url, confidence))
  139. with self._result_lock:
  140. if confidence > self.best.confidence:
  141. self.best = _WorkingResult(url, confidence, (source, delta))
  142. if confidence >= self._min_confidence:
  143. self._finish_early()
  144. class _CopyvioWorker(object):
  145. """A multithreaded URL opener/parser instance."""
  146. def __init__(self, workspace, until, headers, url_timeout):
  147. self.queue = Queue()
  148. self._thread = None
  149. self._workspace = workspace
  150. self._until = until
  151. self._opener = build_opener()
  152. self._opener.addheaders = headers
  153. self._url_timeout = url_timeout
  154. def _open_url(self, url):
  155. """Open a URL and return its parsed content, or None.
  156. First, we will decompress the content if the headers contain "gzip" as
  157. its content encoding. Then, we will return the content stripped using
  158. an HTML parser if the headers indicate it is HTML, or return the
  159. content directly if it is plain text. If we don't understand the
  160. content type, we'll return None.
  161. If a URLError was raised while opening the URL or an IOError was raised
  162. while decompressing, None will be returned.
  163. """
  164. with self._workspace.request_semaphore:
  165. try:
  166. response = self._opener.open(url, timeout=self._url_timeout)
  167. except (URLError, timeout):
  168. return None
  169. try:
  170. size = int(response.headers.get("Content-Length", 0))
  171. except ValueError:
  172. return None
  173. if size > 1024 ** 2: # Ignore URLs larger than a megabyte
  174. return None
  175. ctype_full = response.headers.get("Content-Type", "text/plain")
  176. ctype = ctype_full.split(";", 1)[0]
  177. if ctype in ["text/html", "application/xhtml+xml"]:
  178. handler = lambda res: HTMLTextParser(res).strip()
  179. elif ctype == "text/plain":
  180. handler = lambda res: res.strip()
  181. else:
  182. return None
  183. with self._workspace.request_semaphore:
  184. content = response.read()
  185. if response.headers.get("Content-Encoding") == "gzip":
  186. stream = StringIO(content)
  187. gzipper = GzipFile(fileobj=stream)
  188. try:
  189. content = gzipper.read(2 * 1024 ** 2)
  190. except IOError:
  191. return None
  192. return handler(content)
  193. def _run(self):
  194. """Main entry point for the worker thread.
  195. We will keep fetching URLs from the queue and handling them until
  196. either we run out of time, or we get an exit signal that the queue is
  197. now empty.
  198. """
  199. while True:
  200. if self._until:
  201. max_time = self._until - time()
  202. if max_time <= 0:
  203. return
  204. try:
  205. url = self.queue.get(timeout=max_time)
  206. except Empty:
  207. return
  208. else:
  209. url = self.queue.get()
  210. if url is None: # Exit signal
  211. return
  212. text = self._open_url(url.encode("utf8"))
  213. if text:
  214. self._workspace.compare(url, MarkovChain(text))
  215. def start(self, name):
  216. """Start the worker in a new thread, with a given name."""
  217. self._thread = thread = Thread(target=self._run)
  218. thread.name = "cvworker-" + name
  219. thread.daemon = True
  220. thread.start()
  221. def join(self):
  222. """Join to the worker thread, blocking until it finishes."""
  223. self._thread.join()
  224. class CopyvioMixIn(object):
  225. """
  226. **EarwigBot: Wiki Toolset: Copyright Violation MixIn**
  227. This is a mixin that provides two public methods, :py:meth:`copyvio_check`
  228. and :py:meth:`copyvio_compare`. The former checks the page for copyright
  229. violations using a search engine API, and the latter compares the page
  230. against a given URL. Credentials for the search engine API are stored in
  231. the :py:class:`~earwigbot.wiki.site.Site`'s config.
  232. """
  233. def __init__(self, site):
  234. self._search_config = site._search_config
  235. self._exclusions_db = self._search_config.get("exclusions_db")
  236. self._addheaders = site._opener.addheaders
  237. def _get_search_engine(self):
  238. """Return a function that can be called to do web searches.
  239. The function takes one argument, a search query, and returns a list of
  240. URLs, ranked by importance. The underlying logic depends on the
  241. *engine* argument within our config; for example, if *engine* is
  242. "Yahoo! BOSS", we'll use YahooBOSSSearchEngine for querying.
  243. Raises UnknownSearchEngineError if the 'engine' listed in our config is
  244. unknown to us, and UnsupportedSearchEngineError if we are missing a
  245. required package or module, like oauth2 for "Yahoo! BOSS".
  246. """
  247. engine = self._search_config["engine"]
  248. credentials = self._search_config["credentials"]
  249. if engine == "Yahoo! BOSS":
  250. try:
  251. oauth.__version__ # Force-load the lazy module
  252. except ImportError:
  253. e = "Yahoo! BOSS requires the 'oauth2' package: https://github.com/simplegeo/python-oauth2"
  254. raise exceptions.UnsupportedSearchEngineError(e)
  255. opener = build_opener()
  256. opener.addheaders = self._addheaders
  257. return YahooBOSSSearchEngine(credentials, opener)
  258. raise exceptions.UnknownSearchEngineError(engine)
  259. def copyvio_check(self, min_confidence=0.75, max_queries=15, max_time=-1):
  260. """Check the page for copyright violations.
  261. Returns a :class:`.CopyvioCheckResult` object with information on the
  262. results of the check.
  263. *min_confidence* is the minimum amount of confidence we must have in
  264. the similarity between a source text and the article in order for us to
  265. consider it a suspected violation. This is a number between 0 and 1.
  266. *max_queries* is self-explanatory; we will never make more than this
  267. number of queries in a given check.
  268. *max_time* can be set to prevent copyvio checks from taking longer than
  269. a set amount of time (generally around a minute), which can be useful
  270. if checks are called through a web server with timeouts. We will stop
  271. checking new URLs as soon as this limit is reached.
  272. Raises :exc:`.CopyvioCheckError` or subclasses
  273. (:exc:`.UnknownSearchEngineError`, :exc:`.SearchQueryError`, ...) on
  274. errors.
  275. """
  276. log = u"Starting copyvio check for [[{0}]]"
  277. self._logger.info(log.format(self.title))
  278. start_time = time()
  279. until = (start_time + max_time) if max_time > 0 else None
  280. searcher = self._get_search_engine()
  281. parser = ArticleTextParser(self.get())
  282. article = MarkovChain(parser.strip())
  283. workspace = _CopyvioWorkspace(article, min_confidence, until,
  284. self._logger, self._addheaders)
  285. if self._exclusions_db:
  286. self._exclusions_db.sync(self.site.name)
  287. exclude = lambda u: self._exclusions_db.check(self.site.name, u)
  288. else:
  289. exclude = None
  290. if article.size < 20: # Auto-fail very small articles
  291. result = CopyvioCheckResult(False, 0.0, None, 0, 0, article,
  292. workspace.best.chains)
  293. self._logger.info(result.get_log_message(self.title))
  294. return result
  295. workspace.enqueue(parser.get_links(), exclude)
  296. chunks = parser.chunk(self._search_config["nltk_dir"], max_queries)
  297. num_queries = 0
  298. for chunk in chunks:
  299. if workspace.best.confidence >= min_confidence:
  300. break
  301. log = u"[[{0}]] -> querying {1} for {2!r}"
  302. self._logger.debug(log.format(self.title, searcher.name, chunk))
  303. workspace.enqueue(searcher.search(chunk), exclude)
  304. num_queries += 1
  305. sleep(1)
  306. workspace.wait()
  307. result = CopyvioCheckResult(
  308. workspace.best.confidence >= min_confidence,
  309. workspace.best.confidence, workspace.best.url, num_queries,
  310. time() - start_time, article, workspace.best.chains)
  311. self._logger.info(result.get_log_message(self.title))
  312. return result
  313. def copyvio_compare(self, url, min_confidence=0.75, max_time=30):
  314. """Check the page like :py:meth:`copyvio_check` against a specific URL.
  315. This is essentially a reduced version of :meth:`copyvio_check` - a
  316. copyivo comparison is made using Markov chains and the result is
  317. returned in a :class:`.CopyvioCheckResult` object - but without using a
  318. search engine, since the suspected "violated" URL is supplied from the
  319. start.
  320. Its primary use is to generate a result when the URL is retrieved from
  321. a cache, like the one used in EarwigBot's Tool Labs site. After a
  322. search is done, the resulting URL is stored in a cache for 72 hours so
  323. future checks against that page will not require another set of
  324. time-and-money-consuming search engine queries. However, the comparison
  325. itself (which includes the article's and the source's content) cannot
  326. be stored for data retention reasons, so a fresh comparison is made
  327. using this function.
  328. Since no searching is done, neither :exc:`.UnknownSearchEngineError`
  329. nor :exc:`.SearchQueryError` will be raised.
  330. """
  331. log = u"Starting copyvio compare for [[{0}]] against {1}"
  332. self._logger.info(log.format(self.title, url))
  333. start_time = time()
  334. until = (start_time + max_time) if max_time > 0 else None
  335. article = MarkovChain(ArticleTextParser(self.get()).strip())
  336. workspace = _CopyvioWorkspace(article, min_confidence, until,
  337. self._logger, self._addheaders, max_time)
  338. workspace.enqueue([url])
  339. workspace.wait()
  340. url, conf, chains = workspace.best
  341. result = CopyvioCheckResult(conf >= min_confidence, conf, url, 0,
  342. time() - start_time, article, chains)
  343. self._logger.info(result.get_log_message(self.title))
  344. return result