mirror of https://github.com/scrapy/scrapy.git
371 lines
14 KiB
Python
371 lines
14 KiB
Python
"""
|
|
This is the Scrapy engine which controls the Scheduler, Downloader and Spiders.
|
|
|
|
For more information see docs/topics/architecture.rst
|
|
|
|
"""
|
|
from datetime import datetime
|
|
|
|
from twisted.internet import reactor, task
|
|
from twisted.internet.error import CannotListenError
|
|
from twisted.python.failure import Failure
|
|
from pydispatch import dispatcher
|
|
|
|
from scrapy import log
|
|
from scrapy.conf import settings
|
|
from scrapy.core import signals
|
|
from scrapy.core.scheduler import Scheduler
|
|
from scrapy.core.downloader import Downloader
|
|
from scrapy.core.scraper import Scraper
|
|
from scrapy.core.exceptions import IgnoreRequest, DontCloseDomain
|
|
from scrapy.http import Response, Request
|
|
from scrapy.spider import spiders
|
|
from scrapy.utils.misc import load_object
|
|
from scrapy.utils.defer import mustbe_deferred
|
|
|
|
class ExecutionEngine(object):
|
|
|
|
def __init__(self):
|
|
self.configured = False
|
|
self.keep_alive = False
|
|
self.closing = {} # dict (domain -> reason) of spiders being closed
|
|
self.tasks = []
|
|
self.ports = []
|
|
self.running = False
|
|
self.paused = False
|
|
self.control_reactor = True
|
|
self._next_request_pending = set()
|
|
|
|
def configure(self, scheduler=None, downloader=None):
|
|
"""
|
|
Configure execution engine with the given scheduling policy and downloader.
|
|
"""
|
|
self.scheduler = scheduler or Scheduler()
|
|
self.domain_scheduler = load_object(settings['DOMAIN_SCHEDULER'])()
|
|
self.downloader = downloader or Downloader()
|
|
self.scraper = Scraper(self)
|
|
self.configured = True
|
|
|
|
def addtask(self, function, interval, args=None, kwargs=None, now=False):
|
|
"""
|
|
Adds a looping task. Use this instead of twisted task.LooopingCall to
|
|
make sure the reactor is left in a clean state after the engine is
|
|
stopped.
|
|
"""
|
|
if not args:
|
|
args = []
|
|
if not kwargs:
|
|
kwargs = {}
|
|
tsk = task.LoopingCall(function, *args, **kwargs)
|
|
self.tasks.append((tsk, interval, now))
|
|
if self.running:
|
|
tsk.start(interval, now)
|
|
return tsk
|
|
|
|
def removetask(self, tsk):
|
|
"""Remove a looping task previously added with addtask() method"""
|
|
self.tasks = [(t, i, n) for (t, i, n) in self.tasks if t is not tsk]
|
|
if tsk.running:
|
|
tsk.stop()
|
|
|
|
def listenTCP(self, *args, **kwargs):
|
|
if self.running:
|
|
self.ports.append(reactor.listenTCP(*args, **kwargs))
|
|
else:
|
|
self.ports.append((args, kwargs))
|
|
|
|
def clean_reactor(self):
|
|
"""Leaves the reactor in a clean state by removing all pending tasks
|
|
and listening ports. It can only be called when the engine is not
|
|
running.
|
|
"""
|
|
if not self.running:
|
|
for tsk, _, _ in self.tasks:
|
|
if tsk.running:
|
|
tsk.stop()
|
|
self.tasks = []
|
|
for p in [p for p in self.ports if not isinstance(p, tuple)]:
|
|
p.stopListening()
|
|
self.ports = []
|
|
|
|
def start(self, control_reactor=True):
|
|
"""Start the execution engine"""
|
|
if not self.running:
|
|
self.control_reactor = control_reactor
|
|
reactor.callLater(0, self._mainloop)
|
|
self.start_time = datetime.now()
|
|
signals.send_catch_log(signal=signals.engine_started, sender=self.__class__)
|
|
self.addtask(self._mainloop, 5.0)
|
|
for tsk, interval, now in self.tasks:
|
|
tsk.start(interval, now)
|
|
for args, kwargs in [t for t in self.ports if isinstance(t, tuple)]:
|
|
try:
|
|
reactor.listenTCP(*args, **kwargs)
|
|
except CannotListenError:
|
|
log.msg("Cannot listen on TCP port %d" % args[0], level=log.ERROR)
|
|
self.running = True
|
|
if control_reactor:
|
|
reactor.run() # blocking call
|
|
|
|
def stop(self):
|
|
"""Stop the execution engine"""
|
|
if self.running:
|
|
self.running = False
|
|
for domain in self.open_domains:
|
|
spider = spiders.fromdomain(domain)
|
|
signals.send_catch_log(signal=signals.domain_closed, sender=self.__class__, domain=domain, spider=spider, reason='shutdown')
|
|
for tsk, _, _ in self.tasks: # stop looping calls
|
|
if tsk.running:
|
|
tsk.stop()
|
|
self.tasks = []
|
|
for p in [p for p in self.ports if not isinstance(p, tuple)]:
|
|
p.stopListening()
|
|
if self.control_reactor and reactor.running:
|
|
reactor.stop()
|
|
signals.send_catch_log(signal=signals.engine_stopped, sender=self.__class__)
|
|
|
|
def pause(self):
|
|
"""Pause the execution engine"""
|
|
self.paused = True
|
|
|
|
def unpause(self):
|
|
"""Resume the execution engine"""
|
|
self.paused = False
|
|
|
|
def is_idle(self):
|
|
return self.scheduler.is_idle() and self.downloader.is_idle() and self.scraper.is_idle()
|
|
|
|
def next_domain(self):
|
|
domain = self.domain_scheduler.next_domain()
|
|
if domain:
|
|
self.open_domain(domain)
|
|
return domain
|
|
|
|
def next_request(self, domain, now=False):
|
|
"""Scrape the next request for the domain passed.
|
|
|
|
The next request to be scraped is retrieved from the scheduler and
|
|
requested from the downloader.
|
|
|
|
The domain is closed if there are no more pages to scrape.
|
|
"""
|
|
if now:
|
|
self._next_request_pending.discard(domain)
|
|
elif domain not in self._next_request_pending:
|
|
self._next_request_pending.add(domain)
|
|
return reactor.callLater(0, self.next_request, domain, now=True)
|
|
else:
|
|
return
|
|
|
|
if self.paused:
|
|
return reactor.callLater(5, self.next_request, domain)
|
|
|
|
if not self.running or \
|
|
self.domain_is_closed(domain) or \
|
|
self.downloader.sites[domain].needs_backout():
|
|
return
|
|
|
|
# Next pending request from scheduler
|
|
request, deferred = self.scheduler.next_request(domain)
|
|
if request:
|
|
spider = spiders.fromdomain(domain)
|
|
dwld = mustbe_deferred(self.download, request, spider)
|
|
dwld.chainDeferred(deferred).addBoth(lambda _: deferred)
|
|
dwld.addErrback(log.err)
|
|
elif self.domain_is_idle(domain):
|
|
self._domain_idle(domain)
|
|
|
|
def domain_is_idle(self, domain):
|
|
scraping = domain in self.scraper.sites and bool(self.scraper.sites[domain])
|
|
pending = self.scheduler.domain_has_pending_requests(domain)
|
|
downloading = domain in self.downloader.sites and self.downloader.sites[domain].active
|
|
return not (pending or downloading or scraping)
|
|
|
|
def domain_is_closed(self, domain):
|
|
"""Return True if the domain is fully closed (ie. not even in the
|
|
closing stage)"""
|
|
return domain not in self.downloader.sites
|
|
|
|
def domain_is_open(self, domain):
|
|
"""Return True if the domain is fully opened (ie. not in closing
|
|
stage)"""
|
|
return domain in self.downloader.sites and domain not in self.closing
|
|
|
|
@property
|
|
def open_domains(self):
|
|
return self.downloader.sites.keys()
|
|
|
|
def crawl(self, request, spider):
|
|
schd = mustbe_deferred(self.schedule, request, spider)
|
|
schd.addBoth(self.scraper.scrape, request, spider)
|
|
schd.addErrback(log.err)
|
|
schd.addBoth(lambda _: self.next_request(spider.domain_name))
|
|
|
|
def schedule(self, request, spider):
|
|
domain = spider.domain_name
|
|
if domain in self.closing:
|
|
raise IgnoreRequest()
|
|
if not self.scheduler.domain_is_open(domain):
|
|
self.scheduler.open_domain(domain)
|
|
if self.domain_is_closed(domain): # scheduler auto-open
|
|
self.domain_scheduler.add_domain(domain)
|
|
self.next_request(domain)
|
|
return self.scheduler.enqueue_request(domain, request)
|
|
|
|
def _mainloop(self):
|
|
"""Add more domains to be scraped if the downloader has the capacity.
|
|
|
|
If there is nothing else scheduled then stop the execution engine.
|
|
"""
|
|
if not self.running or self.paused:
|
|
return
|
|
|
|
# main domain starter loop
|
|
while self.running and self.downloader.has_capacity():
|
|
if not self.next_domain():
|
|
return self._stop_if_idle()
|
|
|
|
def download(self, request, spider):
|
|
domain = spider.domain_name
|
|
referer = request.headers.get('Referer', None)
|
|
|
|
def _on_success(response):
|
|
"""handle the result of a page download"""
|
|
assert isinstance(response, (Response, Request))
|
|
if isinstance(response, Response):
|
|
response.request = request # tie request to response received
|
|
log.msg("Crawled %s from <%s>" % (response, referer), level=log.DEBUG, domain=domain)
|
|
return response
|
|
elif isinstance(response, Request):
|
|
newrequest = response
|
|
schd = mustbe_deferred(self.schedule, newrequest, spider)
|
|
schd.chainDeferred(newrequest.deferred)
|
|
return newrequest.deferred
|
|
|
|
def _on_error(_failure):
|
|
"""handle an error processing a page"""
|
|
ex = _failure.value
|
|
errmsg = str(_failure) if not isinstance(ex, IgnoreRequest) else _failure.getErrorMessage()
|
|
log.msg("Downloading <%s> from <%s>: %s" % (request.url, referer, errmsg), log.ERROR, domain=domain)
|
|
return Failure(IgnoreRequest(str(ex)))
|
|
|
|
def _on_complete(_):
|
|
self.next_request(domain)
|
|
return _
|
|
|
|
dwld = mustbe_deferred(self.downloader.fetch, request, spider)
|
|
dwld.addCallbacks(_on_success, _on_error)
|
|
dwld.addBoth(_on_complete)
|
|
return dwld
|
|
|
|
def open_domain(self, domain):
|
|
log.msg("Domain opened", domain=domain)
|
|
spider = spiders.fromdomain(domain)
|
|
self.next_request(domain)
|
|
|
|
self.downloader.open_domain(domain)
|
|
self.scraper.open_domain(domain)
|
|
|
|
signals.send_catch_log(signals.domain_open, sender=self.__class__, domain=domain, spider=spider)
|
|
signals.send_catch_log(signals.domain_opened, sender=self.__class__, domain=domain, spider=spider)
|
|
|
|
def _domain_idle(self, domain):
|
|
"""Called when a domain gets idle. This function is called when there are no
|
|
remaining pages to download or schedule. It can be called multiple
|
|
times. If some extension raises a DontCloseDomain exception (in the
|
|
domain_idle signal handler) the domain is not closed until the next
|
|
loop and this function is guaranteed to be called (at least) once again
|
|
for this domain.
|
|
"""
|
|
spider = spiders.fromdomain(domain)
|
|
try:
|
|
dispatcher.send(signal=signals.domain_idle, sender=self.__class__, domain=domain, spider=spider)
|
|
except DontCloseDomain:
|
|
self.next_request(domain)
|
|
return
|
|
except:
|
|
log.exc("Exception catched on domain_idle signal dispatch")
|
|
if self.domain_is_idle(domain):
|
|
self.close_domain(domain, reason='finished')
|
|
|
|
def _stop_if_idle(self):
|
|
"""Call the stop method if the system has no outstanding tasks. """
|
|
if self.is_idle() and not self.keep_alive:
|
|
self.stop()
|
|
|
|
def close_domain(self, domain, reason='cancelled'):
|
|
"""Close (cancel) domain and clear all its outstanding requests"""
|
|
if domain not in self.closing:
|
|
log.msg("Closing domain (%s)" % reason, domain=domain)
|
|
self.closing[domain] = reason
|
|
self.downloader.close_domain(domain)
|
|
self.scheduler.clear_pending_requests(domain)
|
|
self._finish_closing_domain_if_idle(domain)
|
|
|
|
def _finish_closing_domain_if_idle(self, domain):
|
|
"""Call _finish_closing_domain if domain is idle"""
|
|
if self.domain_is_idle(domain):
|
|
self._finish_closing_domain(domain)
|
|
else:
|
|
reactor.callLater(5, self._finish_closing_domain_if_idle, domain)
|
|
|
|
def _finish_closing_domain(self, domain):
|
|
"""This function is called after the domain has been closed"""
|
|
spider = spiders.fromdomain(domain)
|
|
self.scheduler.close_domain(domain)
|
|
self.scraper.close_domain(domain)
|
|
reason = self.closing.pop(domain, 'finished')
|
|
signals.send_catch_log(signal=signals.domain_closed, sender=self.__class__, domain=domain, spider=spider, reason=reason)
|
|
log.msg("Domain closed (%s)" % reason, domain=domain)
|
|
self._mainloop()
|
|
|
|
def getstatus(self):
|
|
"""
|
|
Return a report of the current engine status
|
|
"""
|
|
s = "Execution engine status\n\n"
|
|
|
|
global_tests = [
|
|
"datetime.now()-self.start_time",
|
|
"self.is_idle()",
|
|
"self.scheduler.is_idle()",
|
|
"len(self.scheduler.pending_requests)",
|
|
"self.downloader.is_idle()",
|
|
"len(self.downloader.sites)",
|
|
"self.downloader.has_capacity()",
|
|
"self.scraper.is_idle()",
|
|
"len(self.scraper.sites)",
|
|
]
|
|
domain_tests = [
|
|
"self.domain_is_idle(domain)",
|
|
"self.closing.get(domain)",
|
|
"self.scheduler.domain_has_pending_requests(domain)",
|
|
"len(self.scheduler.pending_requests[domain])",
|
|
"len(self.downloader.sites[domain].queue)",
|
|
"len(self.downloader.sites[domain].active)",
|
|
"len(self.downloader.sites[domain].transferring)",
|
|
"self.downloader.sites[domain].closing",
|
|
"self.downloader.sites[domain].lastseen",
|
|
"len(self.scraper.sites[domain])",
|
|
]
|
|
|
|
for test in global_tests:
|
|
try:
|
|
s += "%-47s : %s\n" % (test, eval(test))
|
|
except Exception, e:
|
|
s += "%-47s : %s (exception)\n" % (test, type(e).__name__)
|
|
s += "\n"
|
|
for domain in self.downloader.sites:
|
|
s += "%s\n" % domain
|
|
for test in domain_tests:
|
|
try:
|
|
s += " %-50s : %s\n" % (test, eval(test))
|
|
except Exception, e:
|
|
s += " %-50s : %s (exception)\n" % (test, type(e).__name__)
|
|
return s
|
|
|
|
def st(self): # shortcut for printing engine status (useful in telnet console)
|
|
print self.getstatus()
|
|
|
|
scrapyengine = ExecutionEngine()
|