mirror of https://github.com/scrapy/scrapy.git
460 lines
19 KiB
Python
460 lines
19 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 defer, reactor, task
|
|
from twisted.internet.error import CannotListenError
|
|
from twisted.python.failure import Failure
|
|
from pydispatch import dispatcher
|
|
|
|
from scrapy import log
|
|
from scrapy.stats import stats
|
|
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.exceptions import IgnoreRequest, DontCloseDomain
|
|
from scrapy.http import Response, Request
|
|
from scrapy.item import ScrapedItem
|
|
from scrapy.item.pipeline import ItemPipelineManager
|
|
from scrapy.spider import spiders
|
|
from scrapy.spider.middleware import SpiderMiddlewareManager
|
|
from scrapy.utils.defer import chain_deferred, deferred_imap
|
|
from scrapy.utils.request import request_info
|
|
from scrapy.utils.misc import load_object
|
|
|
|
class ExecutionEngine(object):
|
|
"""
|
|
The Execution Engine controls execution of the scraping process.
|
|
|
|
The process begins with the _mainloop() method, which is called
|
|
periodically to add more domains to scrape. It adds the first page for a
|
|
domain to the scheduler by calling _schedule_page and calls
|
|
process_scheduled_requests, which starts the scraping process for a domain.
|
|
|
|
The process_scheduled_requests method asks the scheduler for the next
|
|
available page for a given domain and requests it from the downloader. The
|
|
downloader will execute a callback function that was added in the
|
|
_schedule_page method. This callback with process the output from the
|
|
spider and then call process_scheduled_requests to continue the scraping
|
|
process for that domain.
|
|
"""
|
|
|
|
# Scheduler priority of redirected requests. Negative means high priority.
|
|
# We use high priority to avoid hogging memory with pending redirected requests
|
|
REDIRECTION_PRIORITY = -10
|
|
|
|
def __init__(self):
|
|
self.configured = False
|
|
self.keep_alive = False
|
|
self.cancelled = set() # domains in cancelation state
|
|
self.debug_mode = settings.getbool('ENGINE_DEBUG')
|
|
self.tasks = []
|
|
self.ports = []
|
|
self.running = False
|
|
self.paused = False
|
|
self.control_reactor = True
|
|
|
|
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)
|
|
self.spidermiddleware = SpiderMiddlewareManager()
|
|
self._scraping = {}
|
|
self.pipeline = ItemPipelineManager()
|
|
|
|
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, status='cancelled')
|
|
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 resume(self):
|
|
"""Resume the execution engine"""
|
|
self.paused = False
|
|
|
|
def is_idle(self):
|
|
return self.scheduler.is_idle() and self.pipeline.is_idle() and self.downloader.is_idle() and not self._scraping
|
|
|
|
def next_domain(self):
|
|
domain = self.domain_scheduler.next_domain()
|
|
if domain:
|
|
spider = spiders.fromdomain(domain)
|
|
self.open_domain(domain, spider)
|
|
return domain
|
|
|
|
def next_request(self, spider, breakloop=True):
|
|
"""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 self.paused:
|
|
return reactor.callLater(5, self.next_request, spider)
|
|
|
|
if breakloop:
|
|
# delaying make reentrant call to next_request safe
|
|
return reactor.callLater(0, self.next_request, spider, breakloop=False)
|
|
|
|
domain = spider.domain_name
|
|
|
|
# check that the engine is still running and domain is open
|
|
if not self.running:
|
|
return
|
|
|
|
# backout enqueing downloads if domain needs it
|
|
if domain in self.cancelled or self.downloader.needs_backout(domain):
|
|
return
|
|
|
|
# Next pending request from scheduler
|
|
request, deferred = self.scheduler.next_request(domain)
|
|
if request:
|
|
try:
|
|
dwld = self.download(request, spider)
|
|
except IgnoreRequest, ex:
|
|
log.msg(ex.message, log.WARNING, domain=domain)
|
|
except Exception, ex:
|
|
log.exc("Bug in download code: %s" % request, domain=domain)
|
|
self._domain_idle(domain)
|
|
else:
|
|
chain_deferred(dwld, deferred)
|
|
else:
|
|
if self.domain_is_idle(domain):
|
|
self._domain_idle(domain)
|
|
|
|
def domain_is_idle(self, domain):
|
|
scraping = self._scraping.get(domain)
|
|
pending = self.scheduler.domain_has_pending_requests(domain)
|
|
downloading = not self.downloader.domain_is_idle(domain)
|
|
haspipe = not self.pipeline.domain_is_idle(domain)
|
|
return not (pending or downloading or haspipe or scraping)
|
|
|
|
def domain_is_open(self, domain):
|
|
return domain in self.downloader.sites
|
|
|
|
@property
|
|
def open_domains(self):
|
|
return self.downloader.sites.keys()
|
|
|
|
def crawl(self, request, spider):
|
|
domain = spider.domain_name
|
|
|
|
def _process_response(response):
|
|
assert isinstance(response, (Response, Exception)), "Expecting Response or Exception, got %s" % type(response).__name__
|
|
|
|
def cb_spidermiddleware_output(spmw_result):
|
|
def cb_spider_output(output):
|
|
def cb_pipeline_output(pipe_result, item):
|
|
if isinstance(pipe_result, Failure):
|
|
# can only be a DropItem exception, since other exceptions are caught in the Item Pipeline (item/pipeline.py)
|
|
signals.send_catch_log(signal=signals.item_dropped, sender=self.__class__, item=item, spider=spider, response=response, exception=pipe_result.value)
|
|
else:
|
|
signals.send_catch_log(signal=signals.item_passed, sender=self.__class__, item=item, spider=spider, response=response, pipe_output=pipe_result)
|
|
self.next_request(spider)
|
|
|
|
if domain in self.cancelled:
|
|
return
|
|
elif isinstance(output, ScrapedItem):
|
|
log.msg("Scraped %s in <%s>" % (output, request.url), log.INFO, domain=domain)
|
|
signals.send_catch_log(signal=signals.item_scraped, sender=self.__class__, item=output, spider=spider, response=response)
|
|
piped = self.pipeline.pipe(output, spider)
|
|
piped.addBoth(cb_pipeline_output, output)
|
|
elif isinstance(output, Request):
|
|
signals.send_catch_log(signal=signals.request_received, sender=self.__class__, request=output, spider=spider, response=response)
|
|
self.crawl(request=output, spider=spider)
|
|
elif output is None:
|
|
pass # may be next time.
|
|
else:
|
|
log.msg("Spider must return Request, ScrapedItem or None, got '%s' while processing %s" % (type(output).__name__, request), log.WARNING, domain=domain)
|
|
|
|
return deferred_imap(cb_spider_output, spmw_result)
|
|
|
|
def eb_user(_failure):
|
|
if not isinstance(_failure.value, IgnoreRequest):
|
|
referer = None if not isinstance(response, Response) else response.request.headers.get('Referer', None)
|
|
log.msg("Error while spider was processing <%s> from <%s>: %s" % (request.url, referer, _failure), log.ERROR, domain=domain)
|
|
stats.incpath("%s/spider_exceptions/%s" % (domain, _failure.value.__class__.__name__))
|
|
|
|
def eb_framework(_failure):
|
|
log.msg('FRAMEWORK BUG processing %s: %s' % (request, _failure), log.ERROR, domain=domain)
|
|
|
|
|
|
scd = self.spidermiddleware.scrape(request, response, spider)
|
|
scd.addCallbacks(cb_spidermiddleware_output, eb_user)
|
|
scd.addErrback(eb_framework)
|
|
|
|
self._scraping[domain].add(response)
|
|
scd.addBoth(lambda _: self._scraping[domain].remove(response))
|
|
return scd
|
|
|
|
def _cleanfailure(_failure):
|
|
ex = _failure.value
|
|
if not isinstance(ex, IgnoreRequest):
|
|
log.msg("Unknown error propagated in %s: %s" % (request, _failure), log.ERROR, domain=domain)
|
|
request.deferred.addErrback(lambda _:None)
|
|
request.deferred.errback(_failure) # TODO: merge into spider middleware.
|
|
|
|
schd = self.schedule(request, spider)
|
|
schd.addCallbacks(_process_response, _cleanfailure)
|
|
return schd
|
|
|
|
def schedule(self, request, spider):
|
|
domain = spider.domain_name
|
|
if not self.scheduler.domain_is_open(domain):
|
|
self.scheduler.open_domain(domain)
|
|
if not self.downloader.domain_is_open(domain):
|
|
self.domain_scheduler.add_domain(domain)
|
|
self.next_request(spider)
|
|
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):
|
|
if self.debug_mode:
|
|
log.msg('Downloading %s' % request_info(request), log.DEBUG)
|
|
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 self.debug_mode:
|
|
log.msg("Requested %s" % request_info(request), level=log.DEBUG, domain=domain)
|
|
if isinstance(response, Response):
|
|
response.request = request # tie request to obtained response
|
|
log.msg("Crawled %s from <%s>" % (response, referer), level=log.DEBUG, domain=domain)
|
|
return response
|
|
elif isinstance(response, Request):
|
|
redirected = response # proper alias
|
|
redirected.priority = self.REDIRECTION_PRIORITY
|
|
schd = self.schedule(redirected, spider)
|
|
chain_deferred(schd, redirected.deferred)
|
|
return schd
|
|
|
|
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(spider)
|
|
|
|
dwld = self.downloader.fetch(request, spider)
|
|
dwld.addCallbacks(_on_success, _on_error)
|
|
deferred = defer.Deferred()
|
|
chain_deferred(dwld, deferred)
|
|
dwld.addBoth(_on_complete)
|
|
return deferred
|
|
|
|
def open_domain(self, domain, spider=None):
|
|
log.msg("Domain opened", domain=domain)
|
|
spider = spider or spiders.fromdomain(domain)
|
|
self.next_request(spider)
|
|
|
|
self.cancelled.discard(domain)
|
|
self.downloader.open_domain(domain)
|
|
self.pipeline.open_domain(domain)
|
|
self._scraping[domain] = set()
|
|
|
|
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(spider)
|
|
return
|
|
except:
|
|
log.exc("Exception catched on domain_idle signal dispatch")
|
|
|
|
if self.domain_is_idle(domain):
|
|
self._close_domain(domain)
|
|
|
|
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):
|
|
"""Close (cancel) domain and clear all its outstanding requests"""
|
|
if domain not in self.cancelled:
|
|
log.msg("Closing domain", domain=domain)
|
|
self.cancelled.add(domain)
|
|
self._close_domain(domain)
|
|
|
|
def _close_domain(self, domain):
|
|
self.downloader.close_domain(domain)
|
|
|
|
def closed_domain(self, domain):
|
|
"""
|
|
This function is called after the domain has been closed, and throws
|
|
the domain_closed signal which is meant to be used for cleaning up
|
|
purposes. In contrast to domain_idle, this function is called only
|
|
ONCE for each domain run.
|
|
"""
|
|
spider = spiders.fromdomain(domain)
|
|
self.scheduler.close_domain(domain)
|
|
self.pipeline.close_domain(domain)
|
|
del self._scraping[domain]
|
|
status = 'cancelled' if domain in self.cancelled else 'finished'
|
|
signals.send_catch_log(signal=signals.domain_closed, sender=self.__class__, domain=domain, spider=spider, status=status)
|
|
log.msg("Domain closed (%s)" % status, domain=domain)
|
|
self.cancelled.discard(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_domains)",
|
|
"self.downloader.is_idle()",
|
|
"len(self.downloader.sites)",
|
|
"self.downloader.has_capacity()",
|
|
"self.pipeline.is_idle()",
|
|
"len(self.pipeline.domaininfo)",
|
|
"len(self._scraping)",
|
|
]
|
|
domain_tests = [
|
|
"self.domain_is_idle(domain)",
|
|
"self.scheduler.domain_has_pending_requests(domain)",
|
|
"len(self.scheduler.pending_requests[domain])",
|
|
"self.downloader.outstanding(domain)",
|
|
"len(self.downloader.request_queue(domain))",
|
|
"len(self.downloader.active_requests(domain))",
|
|
"self.pipeline.domain_is_idle(domain)",
|
|
"len(self.pipeline.domaininfo[domain])",
|
|
"len(self._scraping[domain])",
|
|
]
|
|
|
|
for test in global_tests:
|
|
s += "%-47s : %s\n" % (test, eval(test))
|
|
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()
|