From 776dc5e8bd7ee5ad3881eb0428cc0fa7f89d4c8c Mon Sep 17 00:00:00 2001 From: zuev Date: Mon, 30 May 2016 19:12:39 +0300 Subject: [PATCH 01/44] Jobs API update --- arachnado/handlers.py | 4 +- arachnado/pipelines/mongoexport.py | 9 +++ arachnado/rpc/__init__.py | 105 +++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/arachnado/handlers.py b/arachnado/handlers.py index 71ca6a5..1b6ecf1 100644 --- a/arachnado/handlers.py +++ b/arachnado/handlers.py @@ -8,7 +8,7 @@ from arachnado.utils.misc import json_encode from arachnado.monitor import Monitor from arachnado.handler_utils import ApiHandler, NoEtagsMixin -from arachnado.rpc import MainRpcHttpHandler, MainRpcWebsocketHandler +from arachnado.rpc import MainRpcHttpHandler, MainRpcWebsocketHandler, JobsRpcWebsocketHandler, ItemsRpcWebsocketHandler at_root = lambda *args: os.path.join(os.path.dirname(__file__), *args) @@ -37,6 +37,8 @@ def get_application(crawler_process, domain_crawlers, url(r"/ws-updates", Monitor, context, name="ws-updates"), url(r"/ws-rpc", MainRpcWebsocketHandler, context, name="ws-rpc"), url(r"/rpc", MainRpcHttpHandler, context, name="rpc"), + url(r"/ws-jobs", JobsRpcWebsocketHandler, context, name="ws-jobs"), + url(r"/ws-items", ItemsRpcWebsocketHandler, context, name="ws-items"), ] return Application( handlers=handlers, diff --git a/arachnado/pipelines/mongoexport.py b/arachnado/pipelines/mongoexport.py index 9badee9..9749a1c 100644 --- a/arachnado/pipelines/mongoexport.py +++ b/arachnado/pipelines/mongoexport.py @@ -81,6 +81,14 @@ def __init__(self, crawler): def from_crawler(cls, crawler): return cls(crawler) + @classmethod + def get_spider_urls(cls, spider): + options = getattr(spider.crawler, 'start_options', None) + if options and "domain" in options: + return options["domain"] + else: + return " ".join(spider.start_urls) + @tt_coroutine def open_spider(self, spider): try: @@ -94,6 +102,7 @@ def open_spider(self, spider): 'started_at': datetime.datetime.utcnow(), 'status': 'running', 'spider': spider.name, + "urls": self.get_spider_urls(spider), 'options': getattr(spider.crawler, 'start_options', {}), }, upsert=True, new=True) self.job_id = str(job['_id']) diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index a81a269..459677e 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -7,6 +7,8 @@ import tornadorpc from tornadorpc.json import JSONRPCHandler from tornado.concurrent import Future +from tornado import gen +import tornado.ioloop from arachnado.rpc.jobs import JobsRpc from arachnado.rpc.sites import SitesRpc @@ -23,6 +25,7 @@ class MainRpcHttpHandler(JSONRPCHandler): """ Main JsonRpc router for REST requests""" def initialize(self, *args, **kwargs): + print("MainRpcHttpHandler init") self.jobs = JobsRpc(self, *args, **kwargs) self.sites = SitesRpc(self, *args, **kwargs) self.pages = PagesRpc(self, *args, **kwargs) @@ -40,5 +43,107 @@ def _result(self, result): self._RPC_.response(self) + + class MainRpcWebsocketHandler(JsonRpcWebsocketHandler, MainRpcHttpHandler): """ Main JsonRpc router for WS stream""" + + +class JobsRpcWebsocketHandler(MainRpcWebsocketHandler): + """ jobs info for WS stream""" + job_info = {} + delay_mode = False + job_event_type = 'jobs.tailed' + job_hb = None + + @gen.coroutine + def write_event(self, event, data): + print("write_event!!!!!!!!!!!!!") + if event == self.job_event_type and self.delay_mode: + self.job_info[data["id"]] = data + else: + return super(MainRpcWebsocketHandler, self).write_event(event, data) + + def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): + print("subscribe_to_jobs!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print(include) + print(exclude) + conditions = [] + for inc_str in include: + conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) + for exc_str in exclude: + conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) + jobs_q = {} + if len(conditions) == 1: + jobs_q = conditions[0] + elif len(conditions): + jobs_q = {"$and": conditions } + if update_delay > 0: + self.delay_mode = True + self.job_hb = tornado.ioloop.PeriodicCallback( + lambda: self.send_updates(), + update_delay + ) + self.job_hb.start() + self.jobs.subscribe(query=jobs_q) + + def initialize(self, *args, **kwargs): + print("JobsRpcWebsocketHandler init") + self.jobs = JobsRpc(self, *args, **kwargs) + + def send_updates(self): + for job_id in self.job_info: + res = super(JobsRpcWebsocketHandler, self).write_event(self.job_event_type, self.job_info[job_id]) + self.job_info.pop(job_id) + return res + + +class ItemsRpcWebsocketHandler(MainRpcWebsocketHandler): + """ items info for WS stream""" + items = [] + delay_mode = False + page_event_type = 'pages.tailed' + item_hb = None + + @gen.coroutine + def write_event(self, event, data): + print("write_event!!!!!!!!!!!!!") + if event == self.page_event_type and self.delay_mode: + self.items.append(data) + else: + return super(MainRpcWebsocketHandler, self).write_event(event, data) + + def subscribe_to_items(self, site_ids={}, update_delay=0): + print("subscribe_to_items!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print(site_ids) + # print(exclude) + conditions = [] + for site in site_ids: + conditions.append( + {"$and":[{"url":{"$regex": site + '.*'}}, + {"_id":{"$gt":site_ids[site]}} + ]} + ) + items_q = {} + if len(conditions) == 1: + items_q = conditions[0] + elif len(conditions): + items_q = {"$or": conditions } + if update_delay > 0: + self.delay_mode = True + self.item_hb = tornado.ioloop.PeriodicCallback( + lambda: self.send_updates(), + update_delay + ) + self.item_hb.start() + self.pages.subscribe(query=items_q) + + def initialize(self, *args, **kwargs): + print("ItemsRpcWebsocketHandler init") + self.pages = PagesRpc(self, *args, **kwargs) + + def send_updates(self): + for item in self.items: + self.items.remove(item) + return super(ItemsRpcWebsocketHandler, self).write_event(self.page_event_type, item) + From 1c616b18a52003f3cb918113a5d6a6a0c9c225d2 Mon Sep 17 00:00:00 2001 From: zuev Date: Mon, 30 May 2016 21:08:09 +0300 Subject: [PATCH 02/44] items query bug fix --- arachnado/rpc/__init__.py | 55 +++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index 459677e..acf6e4a 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -9,6 +9,7 @@ from tornado.concurrent import Future from tornado import gen import tornado.ioloop +from bson.objectid import ObjectId from arachnado.rpc.jobs import JobsRpc from arachnado.rpc.sites import SitesRpc @@ -25,7 +26,7 @@ class MainRpcHttpHandler(JSONRPCHandler): """ Main JsonRpc router for REST requests""" def initialize(self, *args, **kwargs): - print("MainRpcHttpHandler init") + # print("MainRpcHttpHandler init") self.jobs = JobsRpc(self, *args, **kwargs) self.sites = SitesRpc(self, *args, **kwargs) self.pages = PagesRpc(self, *args, **kwargs) @@ -43,8 +44,6 @@ def _result(self, result): self._RPC_.response(self) - - class MainRpcWebsocketHandler(JsonRpcWebsocketHandler, MainRpcHttpHandler): """ Main JsonRpc router for WS stream""" @@ -58,16 +57,16 @@ class JobsRpcWebsocketHandler(MainRpcWebsocketHandler): @gen.coroutine def write_event(self, event, data): - print("write_event!!!!!!!!!!!!!") + # print("write_event!!!!!!!!!!!!!") if event == self.job_event_type and self.delay_mode: self.job_info[data["id"]] = data else: return super(MainRpcWebsocketHandler, self).write_event(event, data) def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): - print("subscribe_to_jobs!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print(include) - print(exclude) + # print("subscribe_to_jobs!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + # print(include) + # print(exclude) conditions = [] for inc_str in include: conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) @@ -88,14 +87,14 @@ def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): self.jobs.subscribe(query=jobs_q) def initialize(self, *args, **kwargs): - print("JobsRpcWebsocketHandler init") + # print("JobsRpcWebsocketHandler init") self.jobs = JobsRpc(self, *args, **kwargs) def send_updates(self): - for job_id in self.job_info: + job_ids = set(self.job_info.keys()) + for job_id in job_ids: res = super(JobsRpcWebsocketHandler, self).write_event(self.job_event_type, self.job_info[job_id]) self.job_info.pop(job_id) - return res class ItemsRpcWebsocketHandler(MainRpcWebsocketHandler): @@ -107,28 +106,42 @@ class ItemsRpcWebsocketHandler(MainRpcWebsocketHandler): @gen.coroutine def write_event(self, event, data): - print("write_event!!!!!!!!!!!!!") + # print("write_event!!!!!!!!!!!!!") if event == self.page_event_type and self.delay_mode: self.items.append(data) else: return super(MainRpcWebsocketHandler, self).write_event(event, data) def subscribe_to_items(self, site_ids={}, update_delay=0): - print("subscribe_to_items!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print(site_ids) + # print("subscribe_to_items!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + # print(site_ids) # print(exclude) conditions = [] for site in site_ids: + if "url_field" in site_ids[site]: + url_field_name = site_ids[site]["url_field"] + item_id = site_ids[site]["id"] + else: + url_field_name = "url" + item_id = site_ids[site] + item_id = ObjectId(item_id) conditions.append( - {"$and":[{"url":{"$regex": site + '.*'}}, - {"_id":{"$gt":site_ids[site]}} + {"$and":[{url_field_name:{"$regex": site + '.*'}}, + {"_id":{"$gt":item_id}} ]} ) + # conditions.append( + # {"_id":{"$gt":item_id}} + # ) items_q = {} if len(conditions) == 1: items_q = conditions[0] elif len(conditions): - items_q = {"$or": conditions } + items_q = {"$or": conditions} + # print(items_q) + # items_q = {"$and":[{"_id":{"$gt": ObjectId("5731c771a8cb9c2ddb29cdc6")}}, + # {"url":{"$regex":"https://ru-ru.facebook.com.*"}}]} + # print(items_q) if update_delay > 0: self.delay_mode = True self.item_hb = tornado.ioloop.PeriodicCallback( @@ -136,14 +149,16 @@ def subscribe_to_items(self, site_ids={}, update_delay=0): update_delay ) self.item_hb.start() + print("hb started") self.pages.subscribe(query=items_q) def initialize(self, *args, **kwargs): - print("ItemsRpcWebsocketHandler init") + # print("ItemsRpcWebsocketHandler init") self.pages = PagesRpc(self, *args, **kwargs) def send_updates(self): - for item in self.items: - self.items.remove(item) - return super(ItemsRpcWebsocketHandler, self).write_event(self.page_event_type, item) + print("send_updates: {}".format(len(self.items))) + while len(self.items): + item = self.items.pop() + super(ItemsRpcWebsocketHandler, self).write_event(self.page_event_type, item) From 1f334d057d23b94655f6a9234cdab9401d990e11 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 1 Jun 2016 21:36:50 +0300 Subject: [PATCH 03/44] API update --- arachnado/rpc/__init__.py | 123 ++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 64 deletions(-) diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index acf6e4a..fe94aac 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -50,23 +50,46 @@ class MainRpcWebsocketHandler(JsonRpcWebsocketHandler, MainRpcHttpHandler): class JobsRpcWebsocketHandler(MainRpcWebsocketHandler): """ jobs info for WS stream""" - job_info = {} + stored_data = [] delay_mode = False - job_event_type = 'jobs.tailed' - job_hb = None + event_types = ['jobs.tailed'] + data_hb = None + i_args = None + i_kwargs = None + storages = {} @gen.coroutine def write_event(self, event, data): # print("write_event!!!!!!!!!!!!!") - if event == self.job_event_type and self.delay_mode: - self.job_info[data["id"]] = data + if event in self.event_types and self.delay_mode: + self.stored_data.append({"event":event, "data":data}) else: return super(MainRpcWebsocketHandler, self).write_event(event, data) + def init_hb(self, update_delay): + if update_delay > 0: + self.delay_mode = True + self.data_hb = tornado.ioloop.PeriodicCallback( + lambda: self.send_updates(), + update_delay + ) + self.data_hb.start() + + def add_storage(self, mongo_q): + storage = self.create_storage_link() + storage.subscribe(query=mongo_q) + new_id = str(len(self.storages)) + self.storages[new_id] = storage + return new_id + def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): - # print("subscribe_to_jobs!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - # print(include) - # print(exclude) + mongo_q = self.create_query(include=include, exclude=exclude) + self.init_hb(update_delay) + return self.add_storage(mongo_q) + + def create_query(self, **kwargs): + include = kwargs.get("include", []) + exclude = kwargs.get("exclude", []) conditions = [] for inc_str in include: conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) @@ -77,45 +100,38 @@ def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): jobs_q = conditions[0] elif len(conditions): jobs_q = {"$and": conditions } - if update_delay > 0: - self.delay_mode = True - self.job_hb = tornado.ioloop.PeriodicCallback( - lambda: self.send_updates(), - update_delay - ) - self.job_hb.start() - self.jobs.subscribe(query=jobs_q) + return jobs_q + + def cancel_subscription(self, subscription_id): + storage = self.storages.pop(subscription_id) + storage._on_close() def initialize(self, *args, **kwargs): - # print("JobsRpcWebsocketHandler init") - self.jobs = JobsRpc(self, *args, **kwargs) + self.i_args = args + self.i_kwargs = kwargs + # # print("JobsRpcWebsocketHandler init") + # pass + + def create_storage_link(self): + return JobsRpc(self, *self.i_args, **self.i_kwargs) def send_updates(self): - job_ids = set(self.job_info.keys()) - for job_id in job_ids: - res = super(JobsRpcWebsocketHandler, self).write_event(self.job_event_type, self.job_info[job_id]) - self.job_info.pop(job_id) + print("send_updates: {}".format(len(self.stored_data))) + while len(self.stored_data): + item = self.stored_data.pop() + super(MainRpcWebsocketHandler, self).write_event(item["event"], item["data"]) -class ItemsRpcWebsocketHandler(MainRpcWebsocketHandler): +class ItemsRpcWebsocketHandler(JobsRpcWebsocketHandler): """ items info for WS stream""" - items = [] - delay_mode = False - page_event_type = 'pages.tailed' - item_hb = None + #TODO: create basic abstract class + event_types = ['pages.tailed'] - @gen.coroutine - def write_event(self, event, data): - # print("write_event!!!!!!!!!!!!!") - if event == self.page_event_type and self.delay_mode: - self.items.append(data) - else: - return super(MainRpcWebsocketHandler, self).write_event(event, data) + def create_storage_link(self): + return PagesRpc(self, *self.i_args, **self.i_kwargs) - def subscribe_to_items(self, site_ids={}, update_delay=0): - # print("subscribe_to_items!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - # print(site_ids) - # print(exclude) + def create_query(self, **kwargs): + site_ids = kwargs.get("site_ids", {}) conditions = [] for site in site_ids: if "url_field" in site_ids[site]: @@ -130,35 +146,14 @@ def subscribe_to_items(self, site_ids={}, update_delay=0): {"_id":{"$gt":item_id}} ]} ) - # conditions.append( - # {"_id":{"$gt":item_id}} - # ) items_q = {} if len(conditions) == 1: items_q = conditions[0] elif len(conditions): items_q = {"$or": conditions} - # print(items_q) - # items_q = {"$and":[{"_id":{"$gt": ObjectId("5731c771a8cb9c2ddb29cdc6")}}, - # {"url":{"$regex":"https://ru-ru.facebook.com.*"}}]} - # print(items_q) - if update_delay > 0: - self.delay_mode = True - self.item_hb = tornado.ioloop.PeriodicCallback( - lambda: self.send_updates(), - update_delay - ) - self.item_hb.start() - print("hb started") - self.pages.subscribe(query=items_q) - - def initialize(self, *args, **kwargs): - # print("ItemsRpcWebsocketHandler init") - self.pages = PagesRpc(self, *args, **kwargs) - - def send_updates(self): - print("send_updates: {}".format(len(self.items))) - while len(self.items): - item = self.items.pop() - super(ItemsRpcWebsocketHandler, self).write_event(self.page_event_type, item) + return items_q + def subscribe_to_items(self, site_ids={}, update_delay=0): + mongo_q = self.create_query(site_ids=site_ids) + self.init_hb(update_delay) + return self.add_storage(mongo_q) \ No newline at end of file From 08a6e365723d312140fd9c3d97e510b03fcbb69f Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 2 Jun 2016 19:58:51 +0300 Subject: [PATCH 04/44] Jobs API update --- arachnado/rpc/__init__.py | 58 ++++++++++++++++++++++++++++++++++----- arachnado/rpc/jobs.py | 7 +++-- arachnado/rpc/pages.py | 1 + 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index fe94aac..9b59e0c 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -16,6 +16,8 @@ from arachnado.rpc.pages import PagesRpc from arachnado.rpc.ws import JsonRpcWebsocketHandler +from arachnado.crawler_process import agg_stats_changed, CrawlerProcessSignals as CPS + logger = logging.getLogger(__name__) tornadorpc.config.verbose = True @@ -52,15 +54,27 @@ class JobsRpcWebsocketHandler(MainRpcWebsocketHandler): """ jobs info for WS stream""" stored_data = [] delay_mode = False - event_types = ['jobs.tailed'] + event_types = ['stats:changed'] data_hb = None i_args = None i_kwargs = None storages = {} @gen.coroutine - def write_event(self, event, data): - # print("write_event!!!!!!!!!!!!!") + def write_event(self, event, data, handler_id=None): + #TODO: implement job id filtering + if event == 'jobs.tailed' and "id" in data and handler_id: + self.storages[handler_id]["job_ids"].add(data["id"]) + if event in ['stats:changed', 'jobs:state']: + if event == 'stats:changed': + job_id = data[0] + else: + job_id = data["id"] + allowed = False + for storage in self.storages.values(): + allowed = allowed or job_id in storage["job_ids"] + if not allowed: + return if event in self.event_types and self.delay_mode: self.stored_data.append({"event":event, "data":data}) else: @@ -77,9 +91,13 @@ def init_hb(self, update_delay): def add_storage(self, mongo_q): storage = self.create_storage_link() - storage.subscribe(query=mongo_q) new_id = str(len(self.storages)) - self.storages[new_id] = storage + self.storages[new_id] = { + "storage": storage, + "job_ids": set([]) + } + storage.handler_id = new_id + storage.subscribe(query=mongo_q) return new_id def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): @@ -104,17 +122,43 @@ def create_query(self, **kwargs): def cancel_subscription(self, subscription_id): storage = self.storages.pop(subscription_id) - storage._on_close() + if storage: + storage._on_close() + return True + else: + return False def initialize(self, *args, **kwargs): self.i_args = args self.i_kwargs = kwargs + self.cp = kwargs.get("crawler_process", None) # # print("JobsRpcWebsocketHandler init") # pass def create_storage_link(self): return JobsRpc(self, *self.i_args, **self.i_kwargs) + def on_close(self): + logger.debug("connection closed") + self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) + for storage in self.storages.values(): + storage["storage"]._on_close() + super(MainRpcWebsocketHandler, self).on_close() + + def open(self): + logger.debug("new connection") + super(MainRpcWebsocketHandler, self).open() + self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) + + def on_spider_closed(self, spider): + for job in self.cp.jobs: + self.write_event("jobs:state", job) + + def on_stats_changed(self, changes, crawler): + crawl_id = crawler.spider.crawl_id + self.write_event("stats:changed", [crawl_id, changes]) + def send_updates(self): print("send_updates: {}".format(len(self.stored_data))) while len(self.stored_data): @@ -156,4 +200,4 @@ def create_query(self, **kwargs): def subscribe_to_items(self, site_ids={}, update_delay=0): mongo_q = self.create_query(site_ids=site_ids) self.init_hb(update_delay) - return self.add_storage(mongo_q) \ No newline at end of file + return self.add_storage(mongo_q) \ No newline at end of file diff --git a/arachnado/rpc/jobs.py b/arachnado/rpc/jobs.py index a4c78cb..78707d6 100644 --- a/arachnado/rpc/jobs.py +++ b/arachnado/rpc/jobs.py @@ -2,7 +2,7 @@ class JobsRpc(object): - + handler_id = None logger = logging.getLogger(__name__) def __init__(self, handler, job_storage, **kwargs): @@ -21,4 +21,7 @@ def _publish(self, data): # print("jobs rpc :") # print(data) if self.storage.tailing: - self.handler.write_event('jobs.tailed', data) + if self.handler_id: + self.handler.write_event('jobs.tailed', data, handler_id=self.handler_id) + else: + self.handler.write_event('jobs.tailed', data) diff --git a/arachnado/rpc/pages.py b/arachnado/rpc/pages.py index 7f9d660..e20a7e4 100644 --- a/arachnado/rpc/pages.py +++ b/arachnado/rpc/pages.py @@ -2,6 +2,7 @@ class PagesRpc(object): + handler_id = None def __init__(self, handler, item_storage, **kwargs): self.handler = handler From 6aadfbc9ede8931e317c4145d692ecaeb5ac89ff Mon Sep 17 00:00:00 2001 From: zuev Date: Fri, 3 Jun 2016 20:50:25 +0300 Subject: [PATCH 05/44] Jobs/Items API updated --- arachnado/handlers.py | 6 +- arachnado/rpc/__init__.py | 160 +----------------------------------- arachnado/rpc/data.py | 166 ++++++++++++++++++++++++++++++++++++++ arachnado/rpc/ws.py | 9 ++- 4 files changed, 177 insertions(+), 164 deletions(-) create mode 100644 arachnado/rpc/data.py diff --git a/arachnado/handlers.py b/arachnado/handlers.py index 1b6ecf1..35c8f2c 100644 --- a/arachnado/handlers.py +++ b/arachnado/handlers.py @@ -8,7 +8,8 @@ from arachnado.utils.misc import json_encode from arachnado.monitor import Monitor from arachnado.handler_utils import ApiHandler, NoEtagsMixin -from arachnado.rpc import MainRpcHttpHandler, MainRpcWebsocketHandler, JobsRpcWebsocketHandler, ItemsRpcWebsocketHandler +from arachnado.rpc import MainRpcHttpHandler, MainRpcWebsocketHandler +from arachnado.rpc.data import DataRpcWebsocketHandler at_root = lambda *args: os.path.join(os.path.dirname(__file__), *args) @@ -37,8 +38,7 @@ def get_application(crawler_process, domain_crawlers, url(r"/ws-updates", Monitor, context, name="ws-updates"), url(r"/ws-rpc", MainRpcWebsocketHandler, context, name="ws-rpc"), url(r"/rpc", MainRpcHttpHandler, context, name="rpc"), - url(r"/ws-jobs", JobsRpcWebsocketHandler, context, name="ws-jobs"), - url(r"/ws-items", ItemsRpcWebsocketHandler, context, name="ws-items"), + url(r"/ws-data", DataRpcWebsocketHandler, context, name="ws-data"), ] return Application( handlers=handlers, diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index 9b59e0c..fb41572 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -7,17 +7,12 @@ import tornadorpc from tornadorpc.json import JSONRPCHandler from tornado.concurrent import Future -from tornado import gen -import tornado.ioloop -from bson.objectid import ObjectId from arachnado.rpc.jobs import JobsRpc from arachnado.rpc.sites import SitesRpc from arachnado.rpc.pages import PagesRpc from arachnado.rpc.ws import JsonRpcWebsocketHandler -from arachnado.crawler_process import agg_stats_changed, CrawlerProcessSignals as CPS - logger = logging.getLogger(__name__) tornadorpc.config.verbose = True @@ -47,157 +42,4 @@ def _result(self, result): class MainRpcWebsocketHandler(JsonRpcWebsocketHandler, MainRpcHttpHandler): - """ Main JsonRpc router for WS stream""" - - -class JobsRpcWebsocketHandler(MainRpcWebsocketHandler): - """ jobs info for WS stream""" - stored_data = [] - delay_mode = False - event_types = ['stats:changed'] - data_hb = None - i_args = None - i_kwargs = None - storages = {} - - @gen.coroutine - def write_event(self, event, data, handler_id=None): - #TODO: implement job id filtering - if event == 'jobs.tailed' and "id" in data and handler_id: - self.storages[handler_id]["job_ids"].add(data["id"]) - if event in ['stats:changed', 'jobs:state']: - if event == 'stats:changed': - job_id = data[0] - else: - job_id = data["id"] - allowed = False - for storage in self.storages.values(): - allowed = allowed or job_id in storage["job_ids"] - if not allowed: - return - if event in self.event_types and self.delay_mode: - self.stored_data.append({"event":event, "data":data}) - else: - return super(MainRpcWebsocketHandler, self).write_event(event, data) - - def init_hb(self, update_delay): - if update_delay > 0: - self.delay_mode = True - self.data_hb = tornado.ioloop.PeriodicCallback( - lambda: self.send_updates(), - update_delay - ) - self.data_hb.start() - - def add_storage(self, mongo_q): - storage = self.create_storage_link() - new_id = str(len(self.storages)) - self.storages[new_id] = { - "storage": storage, - "job_ids": set([]) - } - storage.handler_id = new_id - storage.subscribe(query=mongo_q) - return new_id - - def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): - mongo_q = self.create_query(include=include, exclude=exclude) - self.init_hb(update_delay) - return self.add_storage(mongo_q) - - def create_query(self, **kwargs): - include = kwargs.get("include", []) - exclude = kwargs.get("exclude", []) - conditions = [] - for inc_str in include: - conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) - for exc_str in exclude: - conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) - jobs_q = {} - if len(conditions) == 1: - jobs_q = conditions[0] - elif len(conditions): - jobs_q = {"$and": conditions } - return jobs_q - - def cancel_subscription(self, subscription_id): - storage = self.storages.pop(subscription_id) - if storage: - storage._on_close() - return True - else: - return False - - def initialize(self, *args, **kwargs): - self.i_args = args - self.i_kwargs = kwargs - self.cp = kwargs.get("crawler_process", None) - # # print("JobsRpcWebsocketHandler init") - # pass - - def create_storage_link(self): - return JobsRpc(self, *self.i_args, **self.i_kwargs) - - def on_close(self): - logger.debug("connection closed") - self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) - for storage in self.storages.values(): - storage["storage"]._on_close() - super(MainRpcWebsocketHandler, self).on_close() - - def open(self): - logger.debug("new connection") - super(MainRpcWebsocketHandler, self).open() - self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) - self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) - - def on_spider_closed(self, spider): - for job in self.cp.jobs: - self.write_event("jobs:state", job) - - def on_stats_changed(self, changes, crawler): - crawl_id = crawler.spider.crawl_id - self.write_event("stats:changed", [crawl_id, changes]) - - def send_updates(self): - print("send_updates: {}".format(len(self.stored_data))) - while len(self.stored_data): - item = self.stored_data.pop() - super(MainRpcWebsocketHandler, self).write_event(item["event"], item["data"]) - - -class ItemsRpcWebsocketHandler(JobsRpcWebsocketHandler): - """ items info for WS stream""" - #TODO: create basic abstract class - event_types = ['pages.tailed'] - - def create_storage_link(self): - return PagesRpc(self, *self.i_args, **self.i_kwargs) - - def create_query(self, **kwargs): - site_ids = kwargs.get("site_ids", {}) - conditions = [] - for site in site_ids: - if "url_field" in site_ids[site]: - url_field_name = site_ids[site]["url_field"] - item_id = site_ids[site]["id"] - else: - url_field_name = "url" - item_id = site_ids[site] - item_id = ObjectId(item_id) - conditions.append( - {"$and":[{url_field_name:{"$regex": site + '.*'}}, - {"_id":{"$gt":item_id}} - ]} - ) - items_q = {} - if len(conditions) == 1: - items_q = conditions[0] - elif len(conditions): - items_q = {"$or": conditions} - return items_q - - def subscribe_to_items(self, site_ids={}, update_delay=0): - mongo_q = self.create_query(site_ids=site_ids) - self.init_hb(update_delay) - return self.add_storage(mongo_q) \ No newline at end of file + """ Main JsonRpc router for WS stream""" \ No newline at end of file diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py new file mode 100644 index 0000000..e03d26c --- /dev/null +++ b/arachnado/rpc/data.py @@ -0,0 +1,166 @@ +import logging + +from arachnado.utils.misc import json_encode +# A little monkey patching to have custom types encoded right +from jsonrpclib import jsonrpc +jsonrpc.jdumps = json_encode +import tornadorpc +from tornado import gen +import tornado.ioloop +from bson.objectid import ObjectId + +from arachnado.rpc.jobs import JobsRpc +from arachnado.rpc.pages import PagesRpc + +from arachnado.crawler_process import agg_stats_changed, CrawlerProcessSignals as CPS +from arachnado.rpc import MainRpcWebsocketHandler + +logger = logging.getLogger(__name__) +tornadorpc.config.verbose = True +tornadorpc.config.short_errors = True + + +class DataRpcWebsocketHandler(MainRpcWebsocketHandler): + """ jobs info for WS stream""" + stored_data = [] + delay_mode = False + event_types = ['stats:changed', 'pages.tailed'] + data_hb = None + i_args = None + i_kwargs = None + storages = {} + + def subscribe_to_items(self, site_ids={}, update_delay=0): + mongo_q = self.create_items_query(site_ids=site_ids) + self.init_hb(update_delay) + return self.add_storage(mongo_q, storage=self.create_items_storage_link()) + + def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): + mongo_q = self.create_jobs_query(include=include, exclude=exclude) + self.init_hb(update_delay) + return self.add_storage(mongo_q, storage=self.create_jobs_storage_link()) + + @gen.coroutine + def write_event(self, event, data, handler_id=None): + if event == 'jobs.tailed' and "id" in data and handler_id: + self.storages[handler_id]["job_ids"].add(data["id"]) + if event in ['stats:changed', 'jobs:state']: + if event == 'stats:changed': + job_id = data[0] + else: + job_id = data["id"] + allowed = False + for storage in self.storages.values(): + allowed = allowed or job_id in storage["job_ids"] + if not allowed: + return + if event in self.event_types and self.delay_mode: + self.stored_data.append({"event":event, "data":data}) + else: + return super(MainRpcWebsocketHandler, self).write_event(event, data) + + def init_hb(self, update_delay): + if update_delay > 0 and not self.data_hb: + self.delay_mode = True + self.data_hb = tornado.ioloop.PeriodicCallback( + lambda: self.send_updates(), + update_delay + ) + self.data_hb.start() + + def add_storage(self, mongo_q, storage): + new_id = str(len(self.storages)) + self.storages[new_id] = { + "storage": storage, + "job_ids": set([]) + } + storage.handler_id = new_id + storage.subscribe(query=mongo_q) + return new_id + + def create_jobs_query(self, include, exclude): + conditions = [] + for inc_str in include: + conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) + for exc_str in exclude: + conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) + jobs_q = {} + if len(conditions) == 1: + jobs_q = conditions[0] + elif len(conditions): + jobs_q = {"$and": conditions } + return jobs_q + + def cancel_subscription(self, subscription_id): + storage = self.storages.pop(subscription_id) + if storage: + storage._on_close() + return True + else: + return False + + def initialize(self, *args, **kwargs): + self.i_args = args + self.i_kwargs = kwargs + self.cp = kwargs.get("crawler_process", None) + + def create_jobs_storage_link(self): + return JobsRpc(self, *self.i_args, **self.i_kwargs) + + def on_close(self): + import traceback + traceback.print_stack() + logger.info("connection closed") + self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.disconnect(self.on_spider_closed, CPS.spider_closed) + for storage in self.storages.values(): + storage["storage"]._on_close() + if self.data_hb: + self.data_hb.stop() + # super(MainRpcWebsocketHandler, self).on_close() + + def open(self): + logger.info("new connection") + super(MainRpcWebsocketHandler, self).open() + self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) + + def on_spider_closed(self, spider): + for job in self.cp.jobs: + self.write_event("jobs:state", job) + + def on_stats_changed(self, changes, crawler): + crawl_id = crawler.spider.crawl_id + self.write_event("stats:changed", [crawl_id, changes]) + + def send_updates(self): + print("send_updates: {}".format(len(self.stored_data))) + while len(self.stored_data): + item = self.stored_data.pop() + super(MainRpcWebsocketHandler, self).write_event(item["event"], item["data"]) + + def create_items_storage_link(self): + return PagesRpc(self, *self.i_args, **self.i_kwargs) + + def create_items_query(self, site_ids): + conditions = [] + for site in site_ids: + if "url_field" in site_ids[site]: + url_field_name = site_ids[site]["url_field"] + item_id = site_ids[site]["id"] + else: + url_field_name = "url" + item_id = site_ids[site] + item_id = ObjectId(item_id) + conditions.append( + {"$and":[{url_field_name:{"$regex": site + '.*'}}, + {"_id":{"$gt":item_id}} + ]} + ) + items_q = {} + if len(conditions) == 1: + items_q = conditions[0] + elif len(conditions): + items_q = {"$or": conditions} + return items_q + diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 0b3fa49..9ec4db9 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -69,10 +69,15 @@ def on_close(self): def write_event(self, event, data): if isinstance(data, basestring): data = json.loads(data) - message = json_encode({'event': event, 'data': data}) try: + message = json_encode({'event': event, 'data': data}) msg_d = self.write_message(message) if msg_d is not None: yield msg_d except WebSocketClosedError: - pass + logging.error("WebSocketClosedError") + except Exception as ex: + logging.error(ex) + logging.error(data) + logging.error(event) + From fc458cf14c64cbc8ccd402c8c2a0fd435eccf382 Mon Sep 17 00:00:00 2001 From: zuev Date: Sun, 5 Jun 2016 23:30:09 +0300 Subject: [PATCH 06/44] API method rename, basic tests for new API --- arachnado/rpc/data.py | 17 +++++++------ tests/README.md | 1 + tests/__init__.py | 0 tests/requirements.txt | 0 tests/test_data.py | 54 ++++++++++++++++++++++++++++++++++++++++++ tests/utils.py | 22 +++++++++++++++++ 6 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/requirements.txt create mode 100644 tests/test_data.py create mode 100644 tests/utils.py diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index e03d26c..f8edd5a 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -30,7 +30,7 @@ class DataRpcWebsocketHandler(MainRpcWebsocketHandler): i_kwargs = None storages = {} - def subscribe_to_items(self, site_ids={}, update_delay=0): + def subscribe_to_pages(self, site_ids={}, update_delay=0): mongo_q = self.create_items_query(site_ids=site_ids) self.init_hb(update_delay) return self.add_storage(mongo_q, storage=self.create_items_storage_link()) @@ -111,8 +111,9 @@ def on_close(self): import traceback traceback.print_stack() logger.info("connection closed") - self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) - self.cp.signals.disconnect(self.on_spider_closed, CPS.spider_closed) + if self.cp: + self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.disconnect(self.on_spider_closed, CPS.spider_closed) for storage in self.storages.values(): storage["storage"]._on_close() if self.data_hb: @@ -122,12 +123,14 @@ def on_close(self): def open(self): logger.info("new connection") super(MainRpcWebsocketHandler, self).open() - self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) - self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) + if self.cp: + self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) def on_spider_closed(self, spider): - for job in self.cp.jobs: - self.write_event("jobs:state", job) + if self.cp: + for job in self.cp.jobs: + self.write_event("jobs:state", job) def on_stats_changed(self, changes, crawler): crawl_id = crawler.spider.crawl_id diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b617f6e --- /dev/null +++ b/tests/README.md @@ -0,0 +1 @@ +python -m tornado.test.runtests tests.test_data diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..b6777bd --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +import tornado +import json + +import utils as u + + +class TestJobsAPI(tornado.testing.AsyncHTTPTestCase): + ws_uri = r"/ws-data" + + def get_app(self): + return u.get_app(self.ws_uri) + + @tornado.testing.gen_test + def test_jobs_no_filter(self): + jobs_command = { + 'event': 'rpc:request', + 'data': { + 'id':0, + 'jsonrpc': '2.0', + 'method': 'subscribe_to_jobs', + 'params': { + }, + }, + } + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + ws_client.write_message(json.dumps(jobs_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + print(json_response) + self.assertTrue("id" in json_response.get("data", {})) + + @tornado.testing.gen_test + def test_pages_no_filter(self): + pages_command = { + 'event': 'rpc:request', + 'data': { + 'id':0, + 'jsonrpc': '2.0', + 'method': 'subscribe_to_pages', + 'params': { + }, + }, + } + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + ws_client.write_message(json.dumps(pages_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + print(json_response) + self.assertTrue("id" in json_response.get("data", {})) + + diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..8542be5 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +import tornado +import json +from arachnado.rpc.data import DataRpcWebsocketHandler +from arachnado.storages.mongotail import MongoTailStorage + + +def get_app(ws_uri): + items_uri = "mongodb://localhost:27017/arachnado/items" + jobs_uri = "mongodb://localhost:27017/arachnado/jobs" + job_storage = MongoTailStorage(jobs_uri, cache=True) + item_storage = MongoTailStorage(items_uri) + context = { + 'crawler_process': None, + 'job_storage': job_storage, + 'item_storage': item_storage, + } + app = tornado.web.Application([ + (ws_uri, DataRpcWebsocketHandler, context) + ]) + return app + From ebd931ed9fffc5af94532bb56d5b09d086a05e22 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 7 Jun 2016 20:35:21 +0300 Subject: [PATCH 07/44] unit tests update --- arachnado/rpc/data.py | 3 ++- tests/README.md | 1 + tests/test_data.py | 27 ++++++++++++++++++++++++++- tests/utils.py | 5 +++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index b8bce98..4309b8f 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -105,7 +105,7 @@ def create_jobs_query(self, include, exclude): return jobs_q def cancel_subscription(self, subscription_id): - storage = self.storages.pop(subscription_id) + storage = self.storages.pop(subscription_id, None) if storage: storage._on_close() return True @@ -119,6 +119,7 @@ def initialize(self, *args, **kwargs): self.dispatcher = Dispatcher() self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages self.dispatcher["subscribe_to_jobs"] = self.subscribe_to_jobs + self.dispatcher["cancel_subscription"] = self.cancel_subscription def create_jobs_storage_link(self): jobs = Jobs(self, *self.i_args, **self.i_kwargs) diff --git a/tests/README.md b/tests/README.md index b617f6e..437f668 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1 +1,2 @@ python -m tornado.test.runtests tests.test_data +python3 -m tornado.test.runtests tests.test_data diff --git a/tests/test_data.py b/tests/test_data.py index b6777bd..b0a4721 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import tornado import json +from tornado import web, websocket -import utils as u +import tests.utils as u class TestJobsAPI(tornado.testing.AsyncHTTPTestCase): @@ -30,6 +31,7 @@ def test_jobs_no_filter(self): json_response = json.loads(response) print(json_response) self.assertTrue("id" in json_response.get("data", {})) + self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) @tornado.testing.gen_test def test_pages_no_filter(self): @@ -50,5 +52,28 @@ def test_pages_no_filter(self): json_response = json.loads(response) print(json_response) self.assertTrue("id" in json_response.get("data", {})) + self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) + @tornado.testing.gen_test + def test_wrong_cancel(self): + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + self.execute_cancel(ws_client, -1, False) + def execute_cancel(self, ws_client, subscription_id, expected): + jobs_command = { + 'event': 'rpc:request', + 'data': { + 'id':0, + 'jsonrpc': '2.0', + 'method': 'cancel_subscription', + 'params': { + "subscription_id": subscription_id + }, + }, + } + ws_client.write_message(json.dumps(jobs_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + print(json_response) + self.assertEqual(json_response.get("data", {}).get("result"), expected) diff --git a/tests/utils.py b/tests/utils.py index 8542be5..deaa78f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,8 +6,9 @@ def get_app(ws_uri): - items_uri = "mongodb://localhost:27017/arachnado/items" - jobs_uri = "mongodb://localhost:27017/arachnado/jobs" + db_uri = "mongodb://localhost:27017/arachnado" + items_uri = "{}/items".format(db_uri) + jobs_uri = "{}/jobs".format(db_uri) job_storage = MongoTailStorage(jobs_uri, cache=True) item_storage = MongoTailStorage(items_uri) context = { From a31e389d825fd1be987dae98532f454bafc9d5e8 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 8 Jun 2016 20:18:46 +0300 Subject: [PATCH 08/44] unit tests update --- tests/jobs.jl | 2 ++ tests/test_data.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/utils.py | 27 ++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/jobs.jl diff --git a/tests/jobs.jl b/tests/jobs.jl new file mode 100644 index 0000000..1338406 --- /dev/null +++ b/tests/jobs.jl @@ -0,0 +1,2 @@ +{"_id": "5749d45fa8cb9c1df532a98a", "started_at": "2016-05-28 17:42:53", "urls": "http://127.0.0.1/", "spider": "generic", "status": "finished", "stats": "{\"memusage/startup\": 42012672, \"arachnado/domain\": \"127.0.0.1\", \"log_count/INFO\": 11, \"downloader/response_count\": 58, \"downloader/response_bytes\": 220480, \"finish_reason\": \"finished\", \"arachnado/start_url\": \"http://127.0.0.1/\", \"mongo_export/items_stored_count\": 58, \"scheduler/dequeued\": 58, \"request_depth_max\": 4, \"start_time\": \"2016-05-28 17:42:53\", \"request_depth_count/2\": 351, \"scheduler/enqueued/disk\": 58, \"downloader/request_bytes\": 27767, \"downloader/request_method_count/GET\": 58, \"memusage/max\": 42012672, \"dupefilter/filtered\": 1054, \"scheduler/dequeued/disk\": 58, \"downloader/response_status_count/200\": 58, \"response_received_count\": 58, \"finish_time\": \"2016-05-28 17:43:19\", \"item_scraped_count\": 58, \"scheduler/enqueued\": 58, \"request_depth_count/1\": 19, \"request_depth_count/0\": 1, \"request_depth_count/3\": 629, \"downloader/request_count\": 58, \"request_depth_count/4\": 112}", "id": "99f123a74e814fd09a9954265595eac0", "options": {"args": {}, "settings": {}, "crawl_id": "99f123a74e814fd09a9954265595eac0", "domain": "http://127.0.0.1/"}, "finished_at": "2016-05-28 17:43:19"} +{"_id": "5749d89da8cb9c1f286e3a90", "started_at": "2016-05-30 12:29:18", "stats_dict": {"downloader/request_method_count/GET": 107, "log_count/INFO": 18, "mongo_export/items_stored_count": 103, "request_depth_count/2": 9718, "scheduler/enqueued": 3711, "scheduler/dequeued": 107, "downloader/request_count": 107, "memusage/max": 97583104, "start_time": "2016-05-30 12:29:18", "downloader/request_bytes": 43053, "dupefilter/filtered": 7451, "scheduler/initial": 0, "downloader/response_count": 107, "arachnado/start_url": "http://example.com/", "scheduler/enqueued/disk": 3711, "request_depth_count/1": 86, "downloader/response_status_count/200": 103, "response_received_count": 103, "request_depth_count/3": 1353, "request_depth_count/0": 1, "memusage/startup": 41951232, "finish_time": "2016-05-30 12:30:27", "item_scraped_count": 103, "scheduler/remaining": 3604, "request_depth_max": 3, "scheduler/dequeued/disk": 107, "finish_reason": "stopped", "log_count/WARNING": 3, "downloader/response_bytes": 3336279, "downloader/response_status_count/301": 3, "downloader/response_status_count/302": 1}, "urls": "http://example.com/", "spider": "generic", "status": "finished", "stats": "{\"memusage/startup\": 41951232, \"scheduler/remaining\": 3604, \"scheduler/initial\": 0, \"log_count/INFO\": 18, \"downloader/response_count\": 107, \"downloader/response_bytes\": 3336279, \"finish_reason\": \"stopped\", \"arachnado/start_url\": \"http://example.com/\", \"mongo_export/items_stored_count\": 103, \"scheduler/dequeued\": 107, \"log_count/WARNING\": 3, \"request_depth_max\": 3, \"start_time\": \"2016-05-30 12:29:18\", \"request_depth_count/2\": 9718, \"scheduler/enqueued/disk\": 3711, \"downloader/request_bytes\": 43053, \"downloader/request_method_count/GET\": 107, \"memusage/max\": 97583104, \"dupefilter/filtered\": 7451, \"scheduler/dequeued/disk\": 107, \"downloader/response_status_count/200\": 103, \"response_received_count\": 103, \"finish_time\": \"2016-05-30 12:30:27\", \"item_scraped_count\": 103, \"scheduler/enqueued\": 3711, \"request_depth_count/1\": 86, \"request_depth_count/0\": 1, \"request_depth_count/3\": 1353, \"downloader/request_count\": 107, \"downloader/response_status_count/301\": 3, \"downloader/response_status_count/302\": 1}", "id": "6c8be393f12a442ca2ec94ec3f89b72e", "options": {"args": {}, "settings": {}, "domain": "http://example.com/"}, "finished_at": "2016-05-30 12:30:27"} \ No newline at end of file diff --git a/tests/test_data.py b/tests/test_data.py index b0a4721..01de118 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -9,9 +9,18 @@ class TestJobsAPI(tornado.testing.AsyncHTTPTestCase): ws_uri = r"/ws-data" + def setUp(self): + print("setUp:") + tornado.ioloop.IOLoop.current().run_sync(u.init_db) + super(TestJobsAPI, self).setUp() + def get_app(self): return u.get_app(self.ws_uri) + # @tornado.testing.gen_test + # def test_fail(self): + # self.assertTrue(False) + @tornado.testing.gen_test def test_jobs_no_filter(self): jobs_command = { @@ -33,6 +42,36 @@ def test_jobs_no_filter(self): self.assertTrue("id" in json_response.get("data", {})) self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) + @tornado.testing.gen_test + def test_jobs_filter_include(self): + jobs_command = { + 'event': 'rpc:request', + 'data': { + 'id':0, + 'jsonrpc': '2.0', + 'method': 'subscribe_to_jobs', + 'params': { + "include":["127.0.0.1"], + }, + }, + } + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + ws_client.write_message(json.dumps(jobs_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + print(json_response) + self.assertTrue("id" in json_response.get("data", {})) + cnt = 0 + while cnt < 1: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.assertFail() + break + cnt += 1 + self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) + @tornado.testing.gen_test def test_pages_no_filter(self): pages_command = { diff --git a/tests/utils.py b/tests/utils.py index deaa78f..9745f2c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,12 +1,17 @@ # -*- coding: utf-8 -*- +import os import tornado import json from arachnado.rpc.data import DataRpcWebsocketHandler from arachnado.storages.mongotail import MongoTailStorage +from arachnado.utils.mongo import motor_from_uri + +def get_db_uri(): + return "mongodb://localhost:27017/arachnado-test" def get_app(ws_uri): - db_uri = "mongodb://localhost:27017/arachnado" + db_uri = get_db_uri() items_uri = "{}/items".format(db_uri) jobs_uri = "{}/jobs".format(db_uri) job_storage = MongoTailStorage(jobs_uri, cache=True) @@ -21,3 +26,23 @@ def get_app(ws_uri): ]) return app + +@tornado.gen.coroutine +def init_db(): + db_uri = get_db_uri() + # items_uri = "{}/items".format(db_uri) + uri = "{}/jobs".format(db_uri) + in_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "jobs.jl") + _, _, _, _, col = motor_from_uri(uri) + col_cnt = yield col.count() + print(col_cnt) + col.drop() + col_cnt = yield col.count() + print(col_cnt) + with open(in_path, "r") as fin: + for text_line in fin: + job = json.loads(text_line) + print(job["_id"]) + res = yield col.insert(job) + + From 752bbaaf19e29b2c8f19a478a91c5f3425fe7208 Mon Sep 17 00:00:00 2001 From: zuev Date: Fri, 10 Jun 2016 15:03:14 +0300 Subject: [PATCH 09/44] cleanup + Readme update --- README.rst | 8 ++++++++ tests/README.md | 2 -- tests/requirements.txt | 0 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 tests/README.md delete mode 100644 tests/requirements.txt diff --git a/README.rst b/README.rst index d95fa01..3f78496 100644 --- a/README.rst +++ b/README.rst @@ -41,6 +41,14 @@ the server:: For available options check https://github.com/TeamHG-Memex/arachnado/blob/master/arachnado/config/defaults.conf. +Test +----------- +To start unit tests for API: + +python -m tornado.test.runtests tests.test_data +or +python3 -m tornado.test.runtests tests.test_data + Development ----------- diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 437f668..0000000 --- a/tests/README.md +++ /dev/null @@ -1,2 +0,0 @@ -python -m tornado.test.runtests tests.test_data -python3 -m tornado.test.runtests tests.test_data diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index e69de29..0000000 From b5b2eeac3a0397d7ec3dc4ce179c87dd0c8a0314 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 15 Jun 2016 21:05:17 +0300 Subject: [PATCH 10/44] Pages API update --- arachnado/rpc/data.py | 21 +++++++++++++++++---- tests/test_data.py | 23 +++++++++++++---------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 4309b8f..7212cd8 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -35,15 +35,28 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): # TODO: allow client to update this max_msg_size = 2**20 - def subscribe_to_pages(self, site_ids={}, update_delay=0): - mongo_q = self.create_pages_query(site_ids=site_ids) + def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): self.init_hb(update_delay) - return self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + if mode == "urls": + mongo_q = self.create_pages_query(site_ids=site_ids) + return { "datatype": "pages_subscription_id", + "id": self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + } + elif mode == "ids": + res = {} + for site_id in site_ids: + mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) + res[site_id] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + return { "datatype": "pages_subscription_id", + "id": res, + } def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): mongo_q = self.create_jobs_query(include=include, exclude=exclude) self.init_hb(update_delay) - return self.add_storage(mongo_q, storage=self.create_jobs_storage_link()) + return { "datatype": "job_subscription_id", + "id": self.add_storage(mongo_q, storage=self.create_jobs_storage_link()) + } @gen.coroutine def write_event(self, event, data, handler_id=None): diff --git a/tests/test_data.py b/tests/test_data.py index 01de118..41a500e 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -26,7 +26,7 @@ def test_jobs_no_filter(self): jobs_command = { 'event': 'rpc:request', 'data': { - 'id':0, + 'id': "test_jobs_0", 'jsonrpc': '2.0', 'method': 'subscribe_to_jobs', 'params': { @@ -39,15 +39,16 @@ def test_jobs_no_filter(self): response = yield ws_client.read_message() json_response = json.loads(response) print(json_response) - self.assertTrue("id" in json_response.get("data", {})) - self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) + subs_id = json_response.get("data", {}).get("result").get("id", -1) + self.assertNotEqual(subs_id, -1) + self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test def test_jobs_filter_include(self): jobs_command = { 'event': 'rpc:request', 'data': { - 'id':0, + 'id': "test_jobs_1", 'jsonrpc': '2.0', 'method': 'subscribe_to_jobs', 'params': { @@ -61,7 +62,8 @@ def test_jobs_filter_include(self): response = yield ws_client.read_message() json_response = json.loads(response) print(json_response) - self.assertTrue("id" in json_response.get("data", {})) + subs_id = json_response.get("data", {}).get("result").get("id", -1) + self.assertNotEqual(subs_id, -1) cnt = 0 while cnt < 1: response = yield ws_client.read_message() @@ -70,14 +72,14 @@ def test_jobs_filter_include(self): self.assertFail() break cnt += 1 - self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) + self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test def test_pages_no_filter(self): pages_command = { 'event': 'rpc:request', 'data': { - 'id':0, + 'id': "test_pages_0", 'jsonrpc': '2.0', 'method': 'subscribe_to_pages', 'params': { @@ -90,8 +92,9 @@ def test_pages_no_filter(self): response = yield ws_client.read_message() json_response = json.loads(response) print(json_response) - self.assertTrue("id" in json_response.get("data", {})) - self.execute_cancel(ws_client, json_response.get("data", {}).get("id", -1), True) + subs_id = json_response.get("data", {}).get("result").get("id", -1) + self.assertNotEqual(subs_id, -1) + self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test def test_wrong_cancel(self): @@ -103,7 +106,7 @@ def execute_cancel(self, ws_client, subscription_id, expected): jobs_command = { 'event': 'rpc:request', 'data': { - 'id':0, + 'id': "test_cancel", 'jsonrpc': '2.0', 'method': 'cancel_subscription', 'params': { From 70bd6db2ca6d37ddf0c062966c301c8773b57a31 Mon Sep 17 00:00:00 2001 From: zuev Date: Sat, 18 Jun 2016 19:45:57 +0300 Subject: [PATCH 11/44] bug fix --- arachnado/handlers.py | 5 +- arachnado/rpc/data.py | 200 +++++++++++++++++++++++++----------------- arachnado/rpc/ws.py | 1 + tests/test_data.py | 13 +-- tests/utils.py | 7 +- 5 files changed, 134 insertions(+), 92 deletions(-) diff --git a/arachnado/handlers.py b/arachnado/handlers.py index 415648c..05ec546 100644 --- a/arachnado/handlers.py +++ b/arachnado/handlers.py @@ -9,7 +9,7 @@ from arachnado.monitor import Monitor from arachnado.handler_utils import ApiHandler, NoEtagsMixin -from arachnado.rpc.data import DataRpcWebsocketHandler +from arachnado.rpc.data import PagesDataRpcWebsocketHandler, JobsDataRpcWebsocketHandler from arachnado.rpc import RpcHttpHandler from arachnado.rpc.ws import RpcWebsocketHandler @@ -41,7 +41,8 @@ def get_application(crawler_process, domain_crawlers, url(r"/ws-updates", Monitor, context, name="ws-updates"), url(r"/ws-rpc", RpcWebsocketHandler, context, name="ws-rpc"), url(r"/rpc", RpcHttpHandler, context, name="rpc"), - url(r"/ws-data", DataRpcWebsocketHandler, context, name="ws-data"), + url(r"/ws-pages-data", PagesDataRpcWebsocketHandler, context, name="ws-pages-data"), + url(r"/ws-jobs-data", JobsDataRpcWebsocketHandler, context, name="ws-jobs-data"), ] return Application( handlers=handlers, diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 7212cd8..55176d4 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -9,6 +9,7 @@ from tornado import gen import tornado.ioloop from bson.objectid import ObjectId +from bson.errors import InvalidId from jsonrpc.dispatcher import Dispatcher from arachnado.rpc.jobs import Jobs @@ -24,10 +25,10 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): - """ jobs and pages API""" + """ basic class for Data API handlers""" stored_data = [] delay_mode = False - event_types = ['stats:changed', 'pages.tailed'] + event_types = [] data_hb = None i_args = None i_kwargs = None @@ -35,48 +36,6 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): # TODO: allow client to update this max_msg_size = 2**20 - def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): - self.init_hb(update_delay) - if mode == "urls": - mongo_q = self.create_pages_query(site_ids=site_ids) - return { "datatype": "pages_subscription_id", - "id": self.add_storage(mongo_q, storage=self.create_pages_storage_link()) - } - elif mode == "ids": - res = {} - for site_id in site_ids: - mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) - res[site_id] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) - return { "datatype": "pages_subscription_id", - "id": res, - } - - def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): - mongo_q = self.create_jobs_query(include=include, exclude=exclude) - self.init_hb(update_delay) - return { "datatype": "job_subscription_id", - "id": self.add_storage(mongo_q, storage=self.create_jobs_storage_link()) - } - - @gen.coroutine - def write_event(self, event, data, handler_id=None): - if event == 'jobs.tailed' and "id" in data and handler_id: - self.storages[handler_id]["job_ids"].add(data["id"]) - if event in ['stats:changed', 'jobs:state']: - if event == 'stats:changed': - job_id = data[0] - else: - job_id = data["id"] - allowed = False - for storage in self.storages.values(): - allowed = allowed or job_id in storage["job_ids"] - if not allowed: - return - if event in self.event_types and self.delay_mode: - self.stored_data.append({"event":event, "data":data}) - else: - return self._send_event(event, data) - def _send_event(self, event, data): message = json_encode({'event': event, 'data': data}) if len(message) < self.max_msg_size: @@ -104,19 +63,6 @@ def add_storage(self, mongo_q, storage): storage.subscribe(query=mongo_q) return new_id - def create_jobs_query(self, include, exclude): - conditions = [] - for inc_str in include: - conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) - for exc_str in exclude: - conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) - jobs_q = {} - if len(conditions) == 1: - jobs_q = conditions[0] - elif len(conditions): - jobs_q = {"$and": conditions } - return jobs_q - def cancel_subscription(self, subscription_id): storage = self.storages.pop(subscription_id, None) if storage: @@ -130,21 +76,12 @@ def initialize(self, *args, **kwargs): self.i_kwargs = kwargs self.cp = kwargs.get("crawler_process", None) self.dispatcher = Dispatcher() - self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages - self.dispatcher["subscribe_to_jobs"] = self.subscribe_to_jobs self.dispatcher["cancel_subscription"] = self.cancel_subscription - def create_jobs_storage_link(self): - jobs = Jobs(self, *self.i_args, **self.i_kwargs) - return jobs - def on_close(self): # import traceback # traceback.print_stack() logger.info("connection closed") - if self.cp: - self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) - self.cp.signals.disconnect(self.on_spider_closed, CPS.spider_closed) for storage in self.storages.values(): storage["storage"]._on_close() if self.data_hb: @@ -154,25 +91,121 @@ def on_close(self): def open(self): logger.info("new connection") super(DataRpcWebsocketHandler, self).open() - if self.cp: - self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) - self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) def on_spider_closed(self, spider): if self.cp: for job in self.cp.jobs: self.write_event("jobs:state", job) - def on_stats_changed(self, changes, crawler): - crawl_id = crawler.spider.crawl_id - self.write_event("stats:changed", [crawl_id, changes]) - def send_updates(self): - print("send_updates: {}".format(len(self.stored_data))) + logger.debug("send_updates: {}".format(len(self.stored_data))) while len(self.stored_data): item = self.stored_data.pop() return self._send_event(item["event"], item["data"]) + +class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): + event_types = ['stats:changed',] + + def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): + mongo_q = self.create_jobs_query(include=include, exclude=exclude) + self.init_hb(update_delay) + return { "datatype": "job_subscription_id", + "id": self.add_storage(mongo_q, storage=self.create_jobs_storage_link()) + } + + @gen.coroutine + def write_event(self, event, data, handler_id=None): + if event == 'jobs.tailed' and "id" in data and handler_id: + self.storages[handler_id]["job_ids"].add(data["id"]) + if event in ['stats:changed', 'jobs:state']: + if event == 'stats:changed': + job_id = data[0] + else: + job_id = data["id"] + allowed = False + for storage in self.storages.values(): + allowed = allowed or job_id in storage["job_ids"] + if not allowed: + return + if event in self.event_types and self.delay_mode: + self.stored_data.append({"event":event, "data":data}) + else: + return self._send_event(event, data) + + def create_jobs_query(self, include, exclude): + conditions = [] + for inc_str in include: + conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) + for exc_str in exclude: + conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) + jobs_q = {} + if len(conditions) == 1: + jobs_q = conditions[0] + elif len(conditions): + jobs_q = {"$and": conditions } + return jobs_q + + def initialize(self, *args, **kwargs): + super(JobsDataRpcWebsocketHandler, self).initialize(*args, **kwargs) + self.dispatcher["subscribe_to_jobs"] = self.subscribe_to_jobs + + def create_jobs_storage_link(self): + jobs = Jobs(self, *self.i_args, **self.i_kwargs) + return jobs + + def on_close(self): + # import traceback + # traceback.print_stack() + logger.info("connection closed") + if self.cp: + self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.disconnect(self.on_spider_closed, CPS.spider_closed) + super(JobsDataRpcWebsocketHandler, self).on_close() + + def open(self): + logger.info("new connection") + super(JobsDataRpcWebsocketHandler, self).open() + if self.cp: + self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) + self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) + + def on_stats_changed(self, changes, crawler): + crawl_id = crawler.spider.crawl_id + self.write_event("stats:changed", [crawl_id, changes]) + + +class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): + """ pages API""" + event_types = ['pages.tailed'] + + def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): + self.init_hb(update_delay) + if mode == "urls": + mongo_q = self.create_pages_query(site_ids=site_ids) + return { "datatype": "pages_subscription_id", + "id": self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + } + elif mode == "ids": + res = {} + for site_id in site_ids: + mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) + res[site_id] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + return { "datatype": "pages_subscription_id", + "id": res, + } + + @gen.coroutine + def write_event(self, event, data, handler_id=None): + if event in self.event_types and self.delay_mode: + self.stored_data.append({"event":event, "data":data}) + else: + return self._send_event(event, data) + + def initialize(self, *args, **kwargs): + super(PagesDataRpcWebsocketHandler, self).initialize(*args, **kwargs) + self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages + def create_pages_storage_link(self): pages = Pages(self, *self.i_args, **self.i_kwargs) return pages @@ -186,16 +219,21 @@ def create_pages_query(self, site_ids): else: url_field_name = "url" item_id = site_ids[site] - item_id = ObjectId(item_id) - conditions.append( - {"$and":[{url_field_name:{"$regex": site + '.*'}}, - {"_id":{"$gt":item_id}} - ]} - ) + try: + item_id = ObjectId(item_id) + conditions.append( + {"$and":[{url_field_name:{"$regex": site + '.*'}}, + {"_id":{"$gt":item_id}} + ]} + ) + except InvalidId: + logger.warning("Invlaid ObjectID: {}, will use url condition only.".format(item_id)) + conditions.append( + {url_field_name:{"$regex": site + '.*'}} + ) items_q = {} if len(conditions) == 1: items_q = conditions[0] elif len(conditions): items_q = {"$or": conditions} return items_q - diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index d33c61f..1772b49 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -51,6 +51,7 @@ def open(self): resource._on_open() self._pinger = PeriodicCallback(lambda: self.ping(b'PING'), 1000 * 15) self._pinger.start() + logger.info("Pinger initiated") def on_close(self): """ Forward on_close event to resource objects. diff --git a/tests/test_data.py b/tests/test_data.py index 41a500e..ba60739 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -7,7 +7,8 @@ class TestJobsAPI(tornado.testing.AsyncHTTPTestCase): - ws_uri = r"/ws-data" + pages_uri = r"/ws-pages-data" + jobs_uri = r"/ws-jobs-data" def setUp(self): print("setUp:") @@ -15,7 +16,7 @@ def setUp(self): super(TestJobsAPI, self).setUp() def get_app(self): - return u.get_app(self.ws_uri) + return u.get_app(self.pages_uri, self.jobs_uri) # @tornado.testing.gen_test # def test_fail(self): @@ -33,7 +34,7 @@ def test_jobs_no_filter(self): }, }, } - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(jobs_command)) response = yield ws_client.read_message() @@ -56,7 +57,7 @@ def test_jobs_filter_include(self): }, }, } - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(jobs_command)) response = yield ws_client.read_message() @@ -86,7 +87,7 @@ def test_pages_no_filter(self): }, }, } - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(pages_command)) response = yield ws_client.read_message() @@ -98,7 +99,7 @@ def test_pages_no_filter(self): @tornado.testing.gen_test def test_wrong_cancel(self): - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.ws_uri + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) self.execute_cancel(ws_client, -1, False) diff --git a/tests/utils.py b/tests/utils.py index 9745f2c..74db7bd 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,7 +2,7 @@ import os import tornado import json -from arachnado.rpc.data import DataRpcWebsocketHandler +from arachnado.rpc.data import PagesDataRpcWebsocketHandler, JobsDataRpcWebsocketHandler from arachnado.storages.mongotail import MongoTailStorage from arachnado.utils.mongo import motor_from_uri @@ -10,7 +10,7 @@ def get_db_uri(): return "mongodb://localhost:27017/arachnado-test" -def get_app(ws_uri): +def get_app(ws_pages_uri, ws_jobs_uri): db_uri = get_db_uri() items_uri = "{}/items".format(db_uri) jobs_uri = "{}/jobs".format(db_uri) @@ -22,7 +22,8 @@ def get_app(ws_uri): 'item_storage': item_storage, } app = tornado.web.Application([ - (ws_uri, DataRpcWebsocketHandler, context) + (ws_pages_uri, PagesDataRpcWebsocketHandler, context), + (ws_jobs_uri, JobsDataRpcWebsocketHandler, context), ]) return app From e127d5f3edf1d285e6785706e0e3bd54f7aebe47 Mon Sep 17 00:00:00 2001 From: zuev Date: Fri, 24 Jun 2016 16:11:20 +0300 Subject: [PATCH 12/44] job event data format fix --- arachnado/rpc/data.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 55176d4..68eea1a 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -106,6 +106,7 @@ def send_updates(self): class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): event_types = ['stats:changed',] + mongo_id_mapping = {} def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): mongo_q = self.create_jobs_query(include=include, exclude=exclude) @@ -116,11 +117,19 @@ def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): @gen.coroutine def write_event(self, event, data, handler_id=None): + event_data = data if event == 'jobs.tailed' and "id" in data and handler_id: self.storages[handler_id]["job_ids"].add(data["id"]) + self.mongo_id_mapping[data["id"]] = data.get("_id", None) if event in ['stats:changed', 'jobs:state']: if event == 'stats:changed': - job_id = data[0] + if len(data) > 1: + job_id = data[0] + event_data = data[1] + # same as crawl_id + event_data["id"] = job_id + # mongo id + event_data["_id"] = self.mongo_id_mapping.get(job_id, "") else: job_id = data["id"] allowed = False @@ -129,9 +138,9 @@ def write_event(self, event, data, handler_id=None): if not allowed: return if event in self.event_types and self.delay_mode: - self.stored_data.append({"event":event, "data":data}) + self.stored_data.append({"event":event, "data":event_data}) else: - return self._send_event(event, data) + return self._send_event(event, event_data) def create_jobs_query(self, include, exclude): conditions = [] From 420b3a9b1b76024354860d483f4212a8db3694e7 Mon Sep 17 00:00:00 2001 From: zuev Date: Fri, 24 Jun 2016 16:39:03 +0300 Subject: [PATCH 13/44] job stats bug fix --- arachnado/rpc/data.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 68eea1a..b71e551 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -100,7 +100,7 @@ def on_spider_closed(self, spider): def send_updates(self): logger.debug("send_updates: {}".format(len(self.stored_data))) while len(self.stored_data): - item = self.stored_data.pop() + item = self.stored_data.pop(0) return self._send_event(item["event"], item["data"]) @@ -125,7 +125,10 @@ def write_event(self, event, data, handler_id=None): if event == 'stats:changed': if len(data) > 1: job_id = data[0] - event_data = data[1] + # dumps for back compatibility + event_data = {"stats": json.dumps(data[1]), + "stats_dict": data[1], + } # same as crawl_id event_data["id"] = job_id # mongo id From d0c3cb7dc340105ddec279873efa4becc32e9629 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 28 Jun 2016 12:56:57 +0300 Subject: [PATCH 14/44] API update --- arachnado/rpc/data.py | 24 ++++++++++++++++-------- tests/test_data.py | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index b71e551..42a617c 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -107,6 +107,7 @@ def send_updates(self): class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): event_types = ['stats:changed',] mongo_id_mapping = {} + job_url_mapping = {} def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): mongo_q = self.create_jobs_query(include=include, exclude=exclude) @@ -121,7 +122,9 @@ def write_event(self, event, data, handler_id=None): if event == 'jobs.tailed' and "id" in data and handler_id: self.storages[handler_id]["job_ids"].add(data["id"]) self.mongo_id_mapping[data["id"]] = data.get("_id", None) + self.job_url_mapping[data["id"]] = data.get("urls", None) if event in ['stats:changed', 'jobs:state']: + job_id = None if event == 'stats:changed': if len(data) > 1: job_id = data[0] @@ -133,11 +136,14 @@ def write_event(self, event, data, handler_id=None): event_data["id"] = job_id # mongo id event_data["_id"] = self.mongo_id_mapping.get(job_id, "") + # job url + event_data["urls"] = self.job_url_mapping.get(job_id, "") else: job_id = data["id"] allowed = False - for storage in self.storages.values(): - allowed = allowed or job_id in storage["job_ids"] + if job_id: + for storage in self.storages.values(): + allowed = allowed or job_id in storage["job_ids"] if not allowed: return if event in self.event_types and self.delay_mode: @@ -193,19 +199,21 @@ class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): self.init_hb(update_delay) + result = { + "datatype": "pages_subscription_id", + "single_subscription_id": "", + "id": {}, + } if mode == "urls": mongo_q = self.create_pages_query(site_ids=site_ids) - return { "datatype": "pages_subscription_id", - "id": self.add_storage(mongo_q, storage=self.create_pages_storage_link()) - } + result["single_subscription_id"] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) elif mode == "ids": res = {} for site_id in site_ids: mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) res[site_id] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) - return { "datatype": "pages_subscription_id", - "id": res, - } + result["id"] = res + return result @gen.coroutine def write_event(self, event, data, handler_id=None): diff --git a/tests/test_data.py b/tests/test_data.py index ba60739..87a1fea 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -93,7 +93,7 @@ def test_pages_no_filter(self): response = yield ws_client.read_message() json_response = json.loads(response) print(json_response) - subs_id = json_response.get("data", {}).get("result").get("id", -1) + subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) self.assertNotEqual(subs_id, -1) self.execute_cancel(ws_client, subs_id, True) From 3281e95090be5520b2f7bb0d667285064f5827f7 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Fri, 1 Jul 2016 16:38:13 +0500 Subject: [PATCH 15/44] TST run tests using tox+pytest --- README.rst | 12 ++++++------ arachnado/manhole.py | 4 ++-- tests/test_data.py | 1 + tox.ini | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 3f78496..fc770a2 100644 --- a/README.rst +++ b/README.rst @@ -41,13 +41,13 @@ the server:: For available options check https://github.com/TeamHG-Memex/arachnado/blob/master/arachnado/config/defaults.conf. -Test ------------ -To start unit tests for API: +Tests +----- + +To run tests make sure tox_ is installed, then +execute ``tox`` command from the source root. -python -m tornado.test.runtests tests.test_data -or -python3 -m tornado.test.runtests tests.test_data +.. _tox: https://testrun.org/tox/latest/ Development ----------- diff --git a/arachnado/manhole.py b/arachnado/manhole.py index 03d4ce0..f120b4f 100644 --- a/arachnado/manhole.py +++ b/arachnado/manhole.py @@ -3,13 +3,13 @@ An interactive Python interpreter available through telnet. """ from __future__ import absolute_import -from twisted.conch.manhole import ColoredManhole -from twisted.conch.insults import insults from twisted.conch.telnet import TelnetTransport, TelnetBootstrapProtocol from twisted.internet import protocol def start(port=None, host=None, telnet_vars=None): + from twisted.conch.manhole import ColoredManhole + from twisted.conch.insults import insults from twisted.internet import reactor port = int(port) if port else 6023 diff --git a/tests/test_data.py b/tests/test_data.py index 87a1fea..e5577ab 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -2,6 +2,7 @@ import tornado import json from tornado import web, websocket +import tornado.testing import tests.utils as u diff --git a/tox.ini b/tox.ini index 1f6d62a..588ef70 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27 +envlist = py27,py35 [testenv] deps = From 122df9cac258303b551c23238f706668e2bf6a5c Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Sat, 2 Jul 2016 03:26:17 +0500 Subject: [PATCH 16/44] update crontier version to match setup.py --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8b5c728..5b795c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,6 @@ pymongo==2.8 # required for motor 0.6.2 docopt == 0.6.2 service_identity json-rpc==1.10.3 -croniter == 0.3.8 +croniter == 0.3.12 autopager == 0.2 autologin-middleware == 0.1.1 From cdc5c7da264dcd0342d7f7e9dfba1f34b79a892c Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Sat, 2 Jul 2016 05:32:38 +0500 Subject: [PATCH 17/44] rename 'subscriptions' to 'events' 'subscription' already means a different thing in arachnado.rpc.data --- arachnado/cron.py | 2 +- arachnado/rpc/sites.py | 13 ++++---- arachnado/rpc/stats.py | 13 ++++---- arachnado/storages/mongo.py | 54 ++++++++++++++++++--------------- arachnado/storages/mongotail.py | 19 +++++++++--- 5 files changed, 56 insertions(+), 45 deletions(-) diff --git a/arachnado/cron.py b/arachnado/cron.py index e192c1e..a4b85d6 100644 --- a/arachnado/cron.py +++ b/arachnado/cron.py @@ -17,7 +17,7 @@ def __init__(self, domain_crawlers, site_storage): self.waiting_calls = {} self.domain_crawlers = domain_crawlers self.site_storage = site_storage - self.site_storage.subscribe(self.site_storage.available_subscriptions, + self.site_storage.subscribe(self.site_storage.available_events, self.rerun) def start(self): diff --git a/arachnado/rpc/sites.py b/arachnado/rpc/sites.py index a547811..b19d92e 100644 --- a/arachnado/rpc/sites.py +++ b/arachnado/rpc/sites.py @@ -22,15 +22,14 @@ def delete(self, site): self.storage.delete(site) def subscribe(self): - for subscription in self.storage.available_subscriptions: + for event_name in self.storage.available_events: self.storage.subscribe( - subscription, - lambda data, subscription=subscription: - self._publish(data, subscription) + event_name, + partial(self._publish, event=event_name) ) def _on_close(self): - self.storage.unsubscribe(self.storage.available_subscriptions) + self.storage.unsubscribe(self.storage.available_events) - def _publish(self, data, subscription): - self.handler.write_event('sites.{}'.format(subscription), data) + def _publish(self, event, data): + self.handler.write_event('sites.{}'.format(event), data) diff --git a/arachnado/rpc/stats.py b/arachnado/rpc/stats.py index 07fa5a5..6c5859c 100644 --- a/arachnado/rpc/stats.py +++ b/arachnado/rpc/stats.py @@ -19,15 +19,14 @@ def patch(self, site): self.storage.update(site) def subscribe(self): - for subscription in self.storage.available_subscriptions: + for event_name in self.storage.available_events: self.storage.subscribe( - subscription, - lambda data, subscription=subscription: - self._publish(data, subscription) + event_name, + partial(self._publish, event=event_name) ) def _on_close(self): - self.storage.unsubscribe(self.storage.available_subscriptions) + self.storage.unsubscribe(self.storage.available_events) - def _publish(self, data, subscription): - self.handler.write_event('stats.{}'.format(subscription), data) + def _publish(self, event, data): + self.handler.write_event('stats.{}'.format(event), data) diff --git a/arachnado/storages/mongo.py b/arachnado/storages/mongo.py index 68eebda..b5d668f 100644 --- a/arachnado/storages/mongo.py +++ b/arachnado/storages/mongo.py @@ -9,7 +9,11 @@ class MongoStorage(object): - + """ + Utility class for working with MongoDB data. + It supports CRUD operations and allows to subscribe to + created/updated/deleted events. + """ def __init__(self, mongo_uri, cache=False): self.mongo_uri = mongo_uri self.cache_flag = cache @@ -17,7 +21,7 @@ def __init__(self, mongo_uri, cache=False): self.signal_manager = SignalManager() # Used for unsubscribe # disconnect() requires reference to original callback - self.subscription_callbacks = {} + self._callbacks = {} if cache: self.cache = defaultdict(dict) else: @@ -29,38 +33,38 @@ def __init__(self, mongo_uri, cache=False): 'deleted': object(), } - def subscribe(self, subscriptions=None, callback=None): - if subscriptions is None: - subscriptions = self.available_subscriptions - if not isinstance(subscriptions, list): - subscriptions = [subscriptions] - for subscription in subscriptions: - try: - self.signal_manager.connect(callback, - self.signals[subscription], - weak=False) - self.subscription_callbacks[subscription] = callback - except KeyError as exc: - raise ValueError('Invalid subscription type: {}'.format(exc)) + def subscribe(self, events=None, callback=None): + if events is None: + events = self.available_events + if not isinstance(events, list): + events = [events] + for event_name in events: + if event_name not in self.signals: + raise ValueError('Invalid event name: {}'.format(event_name)) + self.signal_manager.connect(callback, + self.signals[event_name], + weak=False) + self._callbacks[event_name] = callback - def unsubscribe(self, subscriptions=None): - if subscriptions is None: - subscriptions = self.available_subscriptions - if not isinstance(subscriptions, list): - subscriptions = [subscriptions] - for subscription in subscriptions: + def unsubscribe(self, events=None): + if events is None: + events = self.available_events + if not isinstance(events, list): + events = [events] + for event_name in events: try: self.signal_manager.disconnect( - self.subscription_callbacks[subscription], - self.signals[subscription], + self._callbacks[event_name], + self.signals[event_name], weak=False ) - self.subscription_callbacks.pop(subscription, None) + self._callbacks.pop(event_name, None) except KeyError: + # FIXME: when can it happen? pass @property - def available_subscriptions(self): + def available_events(self): return list(self.signals.keys()) @coroutine diff --git a/arachnado/storages/mongotail.py b/arachnado/storages/mongotail.py index 61c429d..d30f68d 100644 --- a/arachnado/storages/mongotail.py +++ b/arachnado/storages/mongotail.py @@ -6,6 +6,9 @@ class MongoTailStorage(MongoStorage): + """ + This MongoStorage subclass allows to subscribe to a mongo query. + """ fetch_delay = 0 def __init__(self, mongo_uri, *args, **kwargs): @@ -13,18 +16,24 @@ def __init__(self, mongo_uri, *args, **kwargs): self.tailing = False self.signals['tailed'] = object() - def subscribe(self, subscriptions, callback, last_id=None, query=None, + def subscribe(self, events, callback, last_id=None, query=None, fields=None): - if 'tailed' in subscriptions: + if 'tailed' in events: self.tail(query, fields, last_id) - super(MongoTailStorage, self).subscribe(subscriptions, callback) + super(MongoTailStorage, self).subscribe(events, callback) - def unsubscribe(self, subscriptions): - if 'tailed' in subscriptions: + def unsubscribe(self, events): + if 'tailed' in events: self.untail() + # FIXME: shouldn't it unsubscribe from other events, i.e. call super()? @coroutine def tail(self, query=None, fields=None, last_object_id=None): + """ + Execute ``query`` periodically, fetching new results. + ``self.signals['tailed']`` signal with each result is sent + when a new document appears. + """ if self.tailing: raise RuntimeError('This storage is already tailing') self.tailing = True From 948177bb99502d6a3bcedf81801a966918964eae Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Sat, 2 Jul 2016 05:48:12 +0500 Subject: [PATCH 18/44] cleanup: add more comments, remove unused code, fixed missing import --- README.rst | 2 +- arachnado/config/defaults.conf | 11 +++++++---- arachnado/handlers.py | 7 +++++++ arachnado/rpc/__init__.py | 6 +++++- arachnado/rpc/jobs.py | 9 ++++++++- arachnado/rpc/sites.py | 5 ++++- arachnado/rpc/stats.py | 32 -------------------------------- arachnado/storages/memory.py | 0 8 files changed, 32 insertions(+), 40 deletions(-) delete mode 100644 arachnado/rpc/stats.py delete mode 100644 arachnado/storages/memory.py diff --git a/README.rst b/README.rst index fc770a2..388a4f1 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ License is MIT. Install ------- -Arachnado requires Python 2.7. +Arachnado requires Python 2.7 or Python 3.5. To install Arachnado use pip:: pip install arachnado diff --git a/arachnado/config/defaults.conf b/arachnado/config/defaults.conf index 0357e35..b91fe69 100644 --- a/arachnado/config/defaults.conf +++ b/arachnado/config/defaults.conf @@ -5,7 +5,8 @@ [arachnado] ; General Arachnado server options. -; Event loop to use. Allowed values are "twisted", "tornado" and "auto". +; Event loop to use. Allowed values are +; "twisted", "tornado" and "auto". reactor = auto ; Host/port to listen to @@ -30,9 +31,11 @@ DEPTH_LIMIT = 10 ; Packages to load spiders from (separated by whitespace) spider_packages = -; Name of the default spider. It is used for crawling if no custom spider -; is specified or detected. It should support API similar to -; arachnado.spider.CrawlWebsiteSpider (which is the default here) + +; Name of the default spider. It is used for crawling if +; no custom spider is specified or detected. It should support +; API similar to arachnado.spider.CrawlWebsiteSpider +; (which is the default here). default_spider_name = generic [arachnado.storage] diff --git a/arachnado/handlers.py b/arachnado/handlers.py index 05ec546..af5afd6 100644 --- a/arachnado/handlers.py +++ b/arachnado/handlers.py @@ -31,14 +31,19 @@ def get_application(crawler_process, domain_crawlers, debug = opts['arachnado']['debug'] handlers = [ + # UI url(r"/", Index, context, name="index"), url(r"/help", Help, context, name="help"), + + # simple API used by UI url(r"/crawler/start", StartCrawler, context, name="start"), url(r"/crawler/stop", StopCrawler, context, name="stop"), url(r"/crawler/pause", PauseCrawler, context, name="pause"), url(r"/crawler/resume", ResumeCrawler, context, name="resume"), url(r"/crawler/status", CrawlerStatus, context, name="status"), url(r"/ws-updates", Monitor, context, name="ws-updates"), + + # RPC API url(r"/ws-rpc", RpcWebsocketHandler, context, name="ws-rpc"), url(r"/rpc", RpcHttpHandler, context, name="rpc"), url(r"/ws-pages-data", PagesDataRpcWebsocketHandler, context, name="ws-pages-data"), @@ -148,6 +153,8 @@ def control_job(self, job_id): class CrawlerStatus(BaseRequestHandler): """ Status for one or more jobs. """ + # FIXME: does it work? Can we remove it? It is not used + # by Arachnado UI. def get(self): crawl_ids_arg = self.get_argument('crawl_ids', '') diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index 838d5d2..037f099 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -13,6 +13,10 @@ class ArachnadoRPC(object): """ Base class for all Arachnado RPC resources. + Use it as a mixin for tornado.web.RequestHandler subclasses. + + It provides :meth:`handle_request` method which handles + Jobs, Sites and Pages RPC requests. """ def initialize(self, *args, **kwargs): self.dispatcher = Dispatcher() @@ -45,4 +49,4 @@ def post(self): def send_data(self, data): self.write(json_encode(data)) - self.finish() \ No newline at end of file + self.finish() diff --git a/arachnado/rpc/jobs.py b/arachnado/rpc/jobs.py index 5d48ef9..e0c2f40 100644 --- a/arachnado/rpc/jobs.py +++ b/arachnado/rpc/jobs.py @@ -1,15 +1,22 @@ import logging +from arachnado.storages.mongotail import MongoTailStorage + class Jobs(object): + """ + This object is exposed for RPC requests. + It allows to subscribe for scraping job updates. + """ handler_id = None logger = logging.getLogger(__name__) def __init__(self, handler, job_storage, **kwargs): self.handler = handler - self.storage = job_storage + self.storage = job_storage # type: MongoTailStorage def subscribe(self, last_id=0, query=None, fields=None): + """ Subscribe for job updates. """ self.storage.subscribe('tailed', self._publish, last_id=last_id, query=query, fields=fields) diff --git a/arachnado/rpc/sites.py b/arachnado/rpc/sites.py index b19d92e..2ff5735 100644 --- a/arachnado/rpc/sites.py +++ b/arachnado/rpc/sites.py @@ -1,4 +1,7 @@ import logging +from functools import partial + +from arachnado.storages.mongotail import MongoTailStorage class Sites(object): @@ -7,7 +10,7 @@ class Sites(object): def __init__(self, handler, site_storage, **kwargs): self.handler = handler - self.storage = site_storage + self.storage = site_storage # type: MongoTailStorage def list(self): return self.storage.fetch() diff --git a/arachnado/rpc/stats.py b/arachnado/rpc/stats.py deleted file mode 100644 index 6c5859c..0000000 --- a/arachnado/rpc/stats.py +++ /dev/null @@ -1,32 +0,0 @@ -import logging - - -class Stats(object): - - logger = logging.getLogger(__name__) - - def __init__(self, handler, stats_storage, **kwargs): - self.handler = handler - self.storage = stats_storage - - def list(self): - return self.storage.cache.values() - - def post(self, site): - self.storage.create(site) - - def patch(self, site): - self.storage.update(site) - - def subscribe(self): - for event_name in self.storage.available_events: - self.storage.subscribe( - event_name, - partial(self._publish, event=event_name) - ) - - def _on_close(self): - self.storage.unsubscribe(self.storage.available_events) - - def _publish(self, event, data): - self.handler.write_event('stats.{}'.format(event), data) diff --git a/arachnado/storages/memory.py b/arachnado/storages/memory.py deleted file mode 100644 index e69de29..0000000 From fb63ef7b39110846d5fa999755dbb37437948021 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Sat, 2 Jul 2016 06:49:09 +0500 Subject: [PATCH 19/44] cleanup: more comments --- arachnado/rpc/pages.py | 1 + arachnado/rpc/sites.py | 2 +- arachnado/rpc/ws.py | 3 +++ arachnado/storages/mongo.py | 12 +++++++----- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/arachnado/rpc/pages.py b/arachnado/rpc/pages.py index c30adda..4540200 100644 --- a/arachnado/rpc/pages.py +++ b/arachnado/rpc/pages.py @@ -2,6 +2,7 @@ class Pages(object): + """ Pages (scraped items) object exposed via JSON RPC """ handler_id = None def __init__(self, handler, item_storage, **kwargs): diff --git a/arachnado/rpc/sites.py b/arachnado/rpc/sites.py index 2ff5735..c475cdf 100644 --- a/arachnado/rpc/sites.py +++ b/arachnado/rpc/sites.py @@ -5,7 +5,7 @@ class Sites(object): - + """ 'Known sites' object exposed via JSON-RPC """ logger = logging.getLogger(__name__) def __init__(self, handler, site_storage, **kwargs): diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 1772b49..30d30c8 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -62,6 +62,9 @@ def on_close(self): self._pinger.stop() def _resources(self): + # FIXME: remove it, make explicit. This code helps to call _on_close + # methods of rpc.Jobs, rpc.Pages and rpc.Sites when ws connection + # is closed. for resource_name, resource in self.__dict__.items(): if hasattr(RequestHandler, resource_name): continue diff --git a/arachnado/storages/mongo.py b/arachnado/storages/mongo.py index b5d668f..c71ed93 100644 --- a/arachnado/storages/mongo.py +++ b/arachnado/storages/mongo.py @@ -16,22 +16,24 @@ class MongoStorage(object): """ def __init__(self, mongo_uri, cache=False): self.mongo_uri = mongo_uri - self.cache_flag = cache _, _, _, _, self.col = motor_from_uri(mongo_uri) self.signal_manager = SignalManager() # Used for unsubscribe # disconnect() requires reference to original callback self._callbacks = {} - if cache: - self.cache = defaultdict(dict) - else: - self.cache = None self.fetching = False self.signals = { 'created': object(), 'updated': object(), 'deleted': object(), } + # XXX: cache is used in arachnado.cron and arachnado.site_checker. + # Is it needed? + self.cache_flag = cache + if cache: + self.cache = defaultdict(dict) + else: + self.cache = None def subscribe(self, events=None, callback=None): if events is None: From 5df49cf1efc48f95f8f43252f47c8d4d00938404 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Sat, 2 Jul 2016 06:49:56 +0500 Subject: [PATCH 20/44] [WIP] docs --- .gitignore | 1 + docs/Makefile | 225 ++++++++++++++++++++ docs/_static/img/arachnado-0.png | Bin 0 -> 95960 bytes docs/_static/img/arachnado-1.png | Bin 0 -> 173414 bytes docs/conf.py | 338 +++++++++++++++++++++++++++++++ docs/config.rst | 20 ++ docs/http-api.rst | 62 ++++++ docs/index.rst | 37 ++++ docs/intro.rst | 22 ++ docs/json-rpc-api.rst | 48 +++++ docs/make.bat | 281 +++++++++++++++++++++++++ 11 files changed, 1034 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/_static/img/arachnado-0.png create mode 100644 docs/_static/img/arachnado-1.png create mode 100644 docs/conf.py create mode 100644 docs/config.rst create mode 100644 docs/http-api.rst create mode 100644 docs/index.rst create mode 100644 docs/intro.rst create mode 100644 docs/json-rpc-api.rst create mode 100644 docs/make.bat diff --git a/.gitignore b/.gitignore index c72797a..f73f3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ bot_spiders/ .coverage.* htmlcov/ .scrapy +docs/_build diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..e8c61ee --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,225 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Arachnado.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Arachnado.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Arachnado" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Arachnado" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/_static/img/arachnado-0.png b/docs/_static/img/arachnado-0.png new file mode 100644 index 0000000000000000000000000000000000000000..0a87676d18908ca054b9a3d15c096e92127e4941 GIT binary patch literal 95960 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%0A<#_J!StSFSsClJyPajCPwH-=(H)bi!fh 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 b?7p$EwRm9vm97cEj%~9#amKvZ)b;LPe?*Dq literal 0 HcmV?d00001 diff --git a/docs/_static/img/arachnado-1.png b/docs/_static/img/arachnado-1.png new file mode 100644 index 0000000000000000000000000000000000000000..212eb5bf588b310241f00d4d83864e8ed130d89d GIT binary patch literal 173414 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℘v+@gkj;9(VU-Y=sZ0c9mzWQLsY05d)Pr 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%0AGJ|h&hdi#K8N^j3 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 Aw*UYD literal 0 HcmV?d00001 diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..6d3c3f0 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Arachnado documentation build configuration file, created by +# sphinx-quickstart on Fri Jul 1 16:48:37 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Arachnado' +copyright = '2016, TeamHG' +author = 'TeamHG' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.2' +# The full version, including alpha/beta/rc tags. +release = '0.2' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +import sphinx_rtd_theme +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = 'Arachnado v0.2' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +# html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Arachnadodoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Arachnado.tex', 'Arachnado Documentation', + 'TeamHG', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'arachnado', 'Arachnado Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Arachnado', 'Arachnado Documentation', + author, 'Arachnado', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False diff --git a/docs/config.rst b/docs/config.rst new file mode 100644 index 0000000..65d3608 --- /dev/null +++ b/docs/config.rst @@ -0,0 +1,20 @@ +.. _config: + +Configuration +============= + +Arachnado can be configured using a config file. Put it to one of the common +locations: + +* `/etc/arachnado.conf` +* `~/.config/arachnado.conf` +* `~/.arachnado.conf'` + +or pass the file name as an argument when starting the server:: + + arachnado --config ./my-config.conf + +Available options and their default values: + +.. literalinclude:: + ../arachnado/config/defaults.conf diff --git a/docs/http-api.rst b/docs/http-api.rst new file mode 100644 index 0000000..b7d78c6 --- /dev/null +++ b/docs/http-api.rst @@ -0,0 +1,62 @@ +HTTP API +======== + +Arachnado provides HTTP API for starting/stopping crawls. + +To use HTTP API send a POST request with +``Content-Type: application/json`` header; parameters should be in +JSON-encoded POST body. + +/crawler/start +-------------- + +Start a crawling job. Prameters:: + + { + "domain": "", + "args": {}, + "settings": {} + } + +If job is started successfuly, endpoint returns +``{"status": "ok", "job_id": ""}`` object with an ID of a started job. + +In case of errors ``{"status": "error"}`` is returned. + +/crawler/stop +------------- + +Stop a job. Prameters:: + + {"job_id": ""} + +If job is stopped successfuly, endpoint returns +``{"status": "ok"}``, otherwise it returns ``{"status": "error"}``. + + +/crawler/pause +-------------- + +Pause a job. Prameters:: + + {"job_id": ""} + +If job is stopped successfuly, endpoint returns +``{"status": "ok"}``, otherwise it returns ``{"status": "error"}``. + + +/crawler/resume +--------------- + +Resume paused job. Prameters:: + + {"job_id": ""} + +If job is stopped successfuly, endpoint returns +``{"status": "ok"}``, otherwise it returns ``{"status": "error"}``. + + +/crawler/status +--------------- + +TODO diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..12070a9 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,37 @@ +Arachnado +========= + +Arachnado is a tool to crawl a specific website. +It provides a Tornado_-based HTTP API and a web UI for a +Scrapy_-based crawler. + +License is MIT. + +.. _Tornado: http://www.tornadoweb.org +.. _Scrapy: http://scrapy.org/ + +.. toctree:: + :maxdepth: 2 + + intro + config + http-api + json-rpc-api + +Screenshots +----------- + +.. image:: + _static/img/arachnado-0.png + + +.. image:: + _static/img/arachnado-1.png + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/intro.rst b/docs/intro.rst new file mode 100644 index 0000000..a8534c3 --- /dev/null +++ b/docs/intro.rst @@ -0,0 +1,22 @@ +Getting Started +=============== + +Install +------- + +Arachnado requires Python 2.7 or Python 3.5. +To install Arachnado use pip:: + + pip install arachnado + +Run +--- + +To start Arachnado execute ``arachnado`` command:: + + arachnado + +and then visit http://0.0.0.0:8888. + +Run ``arachndo --help`` to see available command-line options. +See also: :ref:`config`. diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst new file mode 100644 index 0000000..6da986c --- /dev/null +++ b/docs/json-rpc-api.rst @@ -0,0 +1,48 @@ +JSON RPC API +============ + +Arachnado provides JSON-RPC_ API for working with jobs and crawled items +(pages). The API works over WebSocket transport. + +**FIXME**: JSON-RPC request objects are wrapped: +``{"event": "rpc:request", "data": }``. +Responses are also wrapped: +``{"event": "rpc:response", "data": }``. + + +JSON-RPC requests have the following format:: + + { + "jsonrpc": "2.0", + + # pass unique request id here; this id will be included in response + "id": 362810, + + # command to execute + "method": "", + "params": {"name": "value"}, + } + +JSON-RPC responses:: + + { + "jsonrpc": "2.0", + + # id of the request + "id": 362810, + + # what command returns + "result": ... + } + +Working with jobs +----------------- + +JSON-RPC API allows to + +* get information about scraping jobs; +* start new crawls; +* subscribe to job updates. + + +.. _JSON-RPC: http://www.jsonrpc.org/specification diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..03d149a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Arachnado.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Arachnado.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end From c6e420943797235d54d383e3c9eede3f51e049f0 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Wed, 6 Jul 2016 02:24:46 +0500 Subject: [PATCH 21/44] fixed RpcWebsocketHandler closing _on_close methods of rpc exposed objects were not called because these objects were no longer instance attributes after switch to json-rpc library. --- arachnado/rpc/__init__.py | 12 +++++++++--- arachnado/rpc/ws.py | 18 +++++------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/arachnado/rpc/__init__.py b/arachnado/rpc/__init__.py index 037f099..e1d5289 100644 --- a/arachnado/rpc/__init__.py +++ b/arachnado/rpc/__init__.py @@ -18,11 +18,17 @@ class ArachnadoRPC(object): It provides :meth:`handle_request` method which handles Jobs, Sites and Pages RPC requests. """ + rpc_objects = tuple() + def initialize(self, *args, **kwargs): + jobs = Jobs(self, *args, **kwargs) + sites = Sites(self, *args, **kwargs) + pages = Pages(self, *args, **kwargs) + self.rpc_objects = [jobs, sites, pages] + self.dispatcher = Dispatcher() - self.dispatcher.add_object(Jobs(self, *args, **kwargs)) - self.dispatcher.add_object(Sites(self, *args, **kwargs)) - self.dispatcher.add_object(Pages(self, *args, **kwargs)) + for obj in self.rpc_objects: + self.dispatcher.add_object(obj) def handle_request(self, body): response = JSONRPCResponseManager.handle(body, self.dispatcher) diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 30d30c8..106a702 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -46,27 +46,19 @@ def write_event(self, event, data): def open(self): """ Forward open event to resource objects. """ - for resource in self._resources(): + logger.debug("Connection opened %s", self) + for resource in self.rpc_objects: if hasattr(resource, '_on_open'): resource._on_open() self._pinger = PeriodicCallback(lambda: self.ping(b'PING'), 1000 * 15) self._pinger.start() - logger.info("Pinger initiated") + logger.debug("Pinger initiated %s", self) def on_close(self): """ Forward on_close event to resource objects. """ - for resource in self._resources(): + logger.debug("Connection closed %s", self) + for resource in self.rpc_objects: if hasattr(resource, '_on_close'): resource._on_close() self._pinger.stop() - - def _resources(self): - # FIXME: remove it, make explicit. This code helps to call _on_close - # methods of rpc.Jobs, rpc.Pages and rpc.Sites when ws connection - # is closed. - for resource_name, resource in self.__dict__.items(): - if hasattr(RequestHandler, resource_name): - continue - yield resource - From 61850f875cfe52a453d4d14d074a5097fd30bcfc Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 6 Jul 2016 20:55:14 +0300 Subject: [PATCH 22/44] Jobs&Pages API update --- arachnado/pipelines/mongoexport.py | 4 +-- arachnado/rpc/data.py | 45 +++++++++++++----------------- tests/items.jl | 1 + tests/test_data.py | 24 +++++++++------- tests/utils.py | 31 ++++++++++---------- 5 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 tests/items.jl diff --git a/arachnado/pipelines/mongoexport.py b/arachnado/pipelines/mongoexport.py index 9a1766e..4429e45 100644 --- a/arachnado/pipelines/mongoexport.py +++ b/arachnado/pipelines/mongoexport.py @@ -85,9 +85,9 @@ def from_crawler(cls, crawler): def get_spider_urls(cls, spider): options = getattr(spider.crawler, 'start_options', None) if options and "domain" in options: - return options["domain"] + return [options["domain"]] else: - return " ".join(spider.start_urls) + return spider.start_urls @tt_coroutine def open_spider(self, spider): diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 42a617c..552b665 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -1,11 +1,6 @@ import logging - -from arachnado.utils.misc import json_encode -# A little monkey patching to have custom types encoded right -# from jsonrpclib import jsonrpc -# jsonrpc.jdumps = json_encode -# import tornadorpc import json +from collections import deque from tornado import gen import tornado.ioloop from bson.objectid import ObjectId @@ -18,15 +13,14 @@ from arachnado.crawler_process import agg_stats_changed, CrawlerProcessSignals as CPS from arachnado.rpc.ws import RpcWebsocketHandler +from arachnado.utils.misc import json_encode logger = logging.getLogger(__name__) -# tornadorpc.config.verbose = True -# tornadorpc.config.short_errors = True class DataRpcWebsocketHandler(RpcWebsocketHandler): """ basic class for Data API handlers""" - stored_data = [] + stored_data = deque() delay_mode = False event_types = [] data_hb = None @@ -52,15 +46,15 @@ def init_hb(self, update_delay): ) self.data_hb.start() - def add_storage(self, mongo_q, storage): - self.dispatcher.add_object(storage) + def add_storage_wrapper(self, mongo_q, storage_wrapper): + self.dispatcher.add_object(storage_wrapper) new_id = str(len(self.storages)) self.storages[new_id] = { - "storage": storage, + "storage": storage_wrapper, "job_ids": set([]) } - storage.handler_id = new_id - storage.subscribe(query=mongo_q) + storage_wrapper.handler_id = new_id + storage_wrapper.subscribe(query=mongo_q) return new_id def cancel_subscription(self, subscription_id): @@ -79,8 +73,6 @@ def initialize(self, *args, **kwargs): self.dispatcher["cancel_subscription"] = self.cancel_subscription def on_close(self): - # import traceback - # traceback.print_stack() logger.info("connection closed") for storage in self.storages.values(): storage["storage"]._on_close() @@ -98,9 +90,8 @@ def on_spider_closed(self, spider): self.write_event("jobs:state", job) def send_updates(self): - logger.debug("send_updates: {}".format(len(self.stored_data))) while len(self.stored_data): - item = self.stored_data.pop(0) + item = self.stored_data.popleft() return self._send_event(item["event"], item["data"]) @@ -113,7 +104,7 @@ def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): mongo_q = self.create_jobs_query(include=include, exclude=exclude) self.init_hb(update_delay) return { "datatype": "job_subscription_id", - "id": self.add_storage(mongo_q, storage=self.create_jobs_storage_link()) + "id": self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_jobs_storage_link()) } @gen.coroutine @@ -128,8 +119,8 @@ def write_event(self, event, data, handler_id=None): if event == 'stats:changed': if len(data) > 1: job_id = data[0] - # dumps for back compatibility - event_data = {"stats": json.dumps(data[1]), + # two fields with same content for back compatibility + event_data = {"stats": data[1], "stats_dict": data[1], } # same as crawl_id @@ -146,6 +137,12 @@ def write_event(self, event, data, handler_id=None): allowed = allowed or job_id in storage["job_ids"] if not allowed: return + if 'stats' in event_data: + if not isinstance(event_data['stats'], dict): + try: + event_data['stats'] = json.loads(event_data['stats']) + except Exception as ex: + logger.warning("Invalid stats field in job {}".format(event_data.get("_id", "MISSING MONGO ID"))) if event in self.event_types and self.delay_mode: self.stored_data.append({"event":event, "data":event_data}) else: @@ -173,8 +170,6 @@ def create_jobs_storage_link(self): return jobs def on_close(self): - # import traceback - # traceback.print_stack() logger.info("connection closed") if self.cp: self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) @@ -206,12 +201,12 @@ def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): } if mode == "urls": mongo_q = self.create_pages_query(site_ids=site_ids) - result["single_subscription_id"] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + result["single_subscription_id"] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) elif mode == "ids": res = {} for site_id in site_ids: mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) - res[site_id] = self.add_storage(mongo_q, storage=self.create_pages_storage_link()) + res[site_id] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) result["id"] = res return result diff --git a/tests/items.jl b/tests/items.jl new file mode 100644 index 0000000..f3df1f7 --- /dev/null +++ b/tests/items.jl @@ -0,0 +1 @@ +{"status" : 200, "body" : "", "_type" : "page", "url" : "http://example.com/index.php", "items" : [ ], "headers" : { "Cache-Control" : [ "private, no-cache=\"set-cookie\"" ], "X-Powered-By" : [ "PHP/5.5.9-1ubuntu4.14" ], "Date" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Content-Type" : [ "text/html; charset=UTF-8" ], "Expires" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Vary" : [ "Accept-Encoding" ], "Server" : [ "Apache/2.4.7 (Ubuntu)" ] }, "meta" : { "download_timeout" : 180, "depth" : 2}} \ No newline at end of file diff --git a/tests/test_data.py b/tests/test_data.py index 87a1fea..990a21c 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -11,17 +11,12 @@ class TestJobsAPI(tornado.testing.AsyncHTTPTestCase): jobs_uri = r"/ws-jobs-data" def setUp(self): - print("setUp:") tornado.ioloop.IOLoop.current().run_sync(u.init_db) super(TestJobsAPI, self).setUp() def get_app(self): return u.get_app(self.pages_uri, self.jobs_uri) - # @tornado.testing.gen_test - # def test_fail(self): - # self.assertTrue(False) - @tornado.testing.gen_test def test_jobs_no_filter(self): jobs_command = { @@ -39,7 +34,6 @@ def test_jobs_no_filter(self): ws_client.write_message(json.dumps(jobs_command)) response = yield ws_client.read_message() json_response = json.loads(response) - print(json_response) subs_id = json_response.get("data", {}).get("result").get("id", -1) self.assertNotEqual(subs_id, -1) self.execute_cancel(ws_client, subs_id, True) @@ -62,7 +56,6 @@ def test_jobs_filter_include(self): ws_client.write_message(json.dumps(jobs_command)) response = yield ws_client.read_message() json_response = json.loads(response) - print(json_response) subs_id = json_response.get("data", {}).get("result").get("id", -1) self.assertNotEqual(subs_id, -1) cnt = 0 @@ -70,8 +63,11 @@ def test_jobs_filter_include(self): response = yield ws_client.read_message() json_response = json.loads(response) if json_response is None: - self.assertFail() + self.assertTrue(False) break + else: + self.assertTrue('stats' in json_response["data"]) + self.assertTrue(isinstance(json_response["data"]["stats"], dict)) cnt += 1 self.execute_cancel(ws_client, subs_id, True) @@ -92,9 +88,18 @@ def test_pages_no_filter(self): ws_client.write_message(json.dumps(pages_command)) response = yield ws_client.read_message() json_response = json.loads(response) - print(json_response) subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) self.assertNotEqual(subs_id, -1) + cnt = 0 + while cnt < 1: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.assertTrue(False) + break + else: + self.assertTrue('url' in json_response["data"]) + cnt += 1 self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test @@ -118,5 +123,4 @@ def execute_cancel(self, ws_client, subscription_id, expected): ws_client.write_message(json.dumps(jobs_command)) response = yield ws_client.read_message() json_response = json.loads(response) - print(json_response) self.assertEqual(json_response.get("data", {}).get("result"), expected) diff --git a/tests/utils.py b/tests/utils.py index 74db7bd..2b9db2d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,6 +6,7 @@ from arachnado.storages.mongotail import MongoTailStorage from arachnado.utils.mongo import motor_from_uri + def get_db_uri(): return "mongodb://localhost:27017/arachnado-test" @@ -29,21 +30,21 @@ def get_app(ws_pages_uri, ws_jobs_uri): @tornado.gen.coroutine -def init_db(): - db_uri = get_db_uri() - # items_uri = "{}/items".format(db_uri) - uri = "{}/jobs".format(db_uri) - in_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "jobs.jl") - _, _, _, _, col = motor_from_uri(uri) - col_cnt = yield col.count() - print(col_cnt) - col.drop() - col_cnt = yield col.count() - print(col_cnt) - with open(in_path, "r") as fin: +def import_file(file_path, mongo_uri): + _, _, _, _, col = motor_from_uri(mongo_uri) + # col.drop() + with open(file_path, "r") as fin: for text_line in fin: - job = json.loads(text_line) - print(job["_id"]) - res = yield col.insert(job) + record = json.loads(text_line) + yield col.insert(record) +@tornado.gen.coroutine +def init_db(): + db_uri = get_db_uri() + jobs_uri = "{}/jobs".format(db_uri) + jobs_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "jobs.jl") + import_file(jobs_path, jobs_uri) + items_uri = "{}/items".format(db_uri) + items_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "items.jl") + import_file(items_path, items_uri) \ No newline at end of file From 5257384f909e02ccb97fce02a8dc9bae5cb0e8c1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Jul 2016 07:33:19 +0000 Subject: [PATCH 23/44] Dockerfile update --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9c61714..fae9297 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,12 +35,12 @@ RUN npm install # install arachnado COPY . /app +RUN pip install --editable /app # npm install is executed again because node_modules can be overwritten # if .dockerignore is not active (may happen with docker-compose or DockerHub) RUN npm install RUN npm run build -RUN pip3 install . # use e.g. -v /path/to/my/arachnado/config.conf:/etc/arachnado.conf # docker run option to override arachnado parameters @@ -49,7 +49,7 @@ VOLUME /etc/arachnado.conf # this folder is added to PYTHONPATH, so modules from there are available # for spider_packages Arachnado option VOLUME /python-packages -ENV PYTHONPATH $PYTHONPATH:/python-packages +ENV PYTHONPATH $PYTHONPATH:/python-packages:/app EXPOSE 8888 -ENTRYPOINT ["arachnado"] +CMD ["arachnado"] From 74a3fb874e422cbe5df74c2c3308d68fce28cae0 Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 7 Jul 2016 14:43:57 +0300 Subject: [PATCH 24/44] bug fix --- arachnado/rpc/data.py | 40 +++++++++++++++++++++------------------- tests/test_data.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 552b665..3d3b631 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -227,25 +227,27 @@ def create_pages_storage_link(self): def create_pages_query(self, site_ids): conditions = [] - for site in site_ids: - if "url_field" in site_ids[site]: - url_field_name = site_ids[site]["url_field"] - item_id = site_ids[site]["id"] - else: - url_field_name = "url" - item_id = site_ids[site] - try: - item_id = ObjectId(item_id) - conditions.append( - {"$and":[{url_field_name:{"$regex": site + '.*'}}, - {"_id":{"$gt":item_id}} - ]} - ) - except InvalidId: - logger.warning("Invlaid ObjectID: {}, will use url condition only.".format(item_id)) - conditions.append( - {url_field_name:{"$regex": site + '.*'}} - ) + if site_ids: + for site in site_ids: + if site_ids[site]: + if "url_field" in site_ids[site]: + url_field_name = site_ids[site]["url_field"] + item_id = site_ids[site]["id"] + else: + url_field_name = "url" + item_id = site_ids[site] + try: + item_id = ObjectId(item_id) + conditions.append( + {"$and":[{url_field_name:{"$regex": site + '.*'}}, + {"_id":{"$gt":item_id}} + ]} + ) + except InvalidId: + logger.warning("Invlaid ObjectID: {}, will use url condition only.".format(item_id)) + conditions.append( + {url_field_name:{"$regex": site + '.*'}} + ) items_q = {} if len(conditions) == 1: items_q = conditions[0] diff --git a/tests/test_data.py b/tests/test_data.py index 40142a8..adddab6 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -103,6 +103,39 @@ def test_pages_no_filter(self): cnt += 1 self.execute_cancel(ws_client, subs_id, True) + @tornado.testing.gen_test + def test_pages_filter(self): + pages_command = { + 'event': 'rpc:request', + 'data': { + 'id': "test_pages_0", + 'jsonrpc': '2.0', + 'method': 'subscribe_to_pages', + 'params': {'mode': 'ids', + 'site_ids': {1: {'http://example.com': None}} + } + }, + } + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + ws_client.write_message(json.dumps(pages_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) + self.assertNotEqual(subs_id, -1) + cnt = 0 + while cnt < 1: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.assertTrue(False) + break + else: + self.assertTrue('url' in json_response["data"]) + cnt += 1 + self.execute_cancel(ws_client, subs_id, True) + + @tornado.testing.gen_test def test_wrong_cancel(self): ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri From 34156a8fd65450cb22e6817bfc4e76a479e737d0 Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 7 Jul 2016 14:54:11 +0300 Subject: [PATCH 25/44] bug fix --- arachnado/rpc/data.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 3d3b631..fb309d8 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -229,12 +229,13 @@ def create_pages_query(self, site_ids): conditions = [] if site_ids: for site in site_ids: + url_only = False + url_field_name = "url" if site_ids[site]: if "url_field" in site_ids[site]: url_field_name = site_ids[site]["url_field"] item_id = site_ids[site]["id"] else: - url_field_name = "url" item_id = site_ids[site] try: item_id = ObjectId(item_id) @@ -245,9 +246,13 @@ def create_pages_query(self, site_ids): ) except InvalidId: logger.warning("Invlaid ObjectID: {}, will use url condition only.".format(item_id)) - conditions.append( - {url_field_name:{"$regex": site + '.*'}} - ) + url_only = True + else: + url_only = True + if url_only: + conditions.append( + {url_field_name:{"$regex": site + '.*'}} + ) items_q = {} if len(conditions) == 1: items_q = conditions[0] From 985d9b6c14cb096a497f9e89bfb74f6b7a3092dc Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 7 Jul 2016 19:59:26 +0300 Subject: [PATCH 26/44] bug fix + tests update --- arachnado/rpc/data.py | 43 ++++++++++++++++++++++++++++--------------- tests/test_data.py | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index fb309d8..d8e4910 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -20,14 +20,14 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): """ basic class for Data API handlers""" - stored_data = deque() + stored_data = None delay_mode = False + # static variable, same for all instances event_types = [] data_hb = None i_args = None i_kwargs = None - storages = {} - # TODO: allow client to update this + storages = None max_msg_size = 2**20 def _send_event(self, event, data): @@ -65,12 +65,18 @@ def cancel_subscription(self, subscription_id): else: return False + def set_max_message_size(self, max_size): + self.max_msg_size = max_size + def initialize(self, *args, **kwargs): + self.stored_data = deque() + self.storages = {} self.i_args = args self.i_kwargs = kwargs self.cp = kwargs.get("crawler_process", None) self.dispatcher = Dispatcher() self.dispatcher["cancel_subscription"] = self.cancel_subscription + self.dispatcher["set_max_message_size"] = self.set_max_message_size def on_close(self): logger.info("connection closed") @@ -97,13 +103,13 @@ def send_updates(self): class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): event_types = ['stats:changed',] - mongo_id_mapping = {} - job_url_mapping = {} + mongo_id_mapping = None + job_url_mapping = None def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): mongo_q = self.create_jobs_query(include=include, exclude=exclude) self.init_hb(update_delay) - return { "datatype": "job_subscription_id", + return {"datatype": "job_subscription_id", "id": self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_jobs_storage_link()) } @@ -164,6 +170,8 @@ def create_jobs_query(self, include, exclude): def initialize(self, *args, **kwargs): super(JobsDataRpcWebsocketHandler, self).initialize(*args, **kwargs) self.dispatcher["subscribe_to_jobs"] = self.subscribe_to_jobs + self.mongo_id_mapping = {} + self.job_url_mapping = {} def create_jobs_storage_link(self): jobs = Jobs(self, *self.i_args, **self.i_kwargs) @@ -192,7 +200,7 @@ class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): """ pages API""" event_types = ['pages.tailed'] - def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): + def subscribe_to_pages(self, site_ids=None, update_delay=0, mode="urls"): self.init_hb(update_delay) result = { "datatype": "pages_subscription_id", @@ -204,9 +212,13 @@ def subscribe_to_pages(self, site_ids={}, update_delay=0, mode="urls"): result["single_subscription_id"] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) elif mode == "ids": res = {} - for site_id in site_ids: - mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) - res[site_id] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) + if site_ids: + for site_id in site_ids: + mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) + res[site_id] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) + else: + mongo_q = {} + result["single_subscription_id"] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) result["id"] = res return result @@ -231,12 +243,13 @@ def create_pages_query(self, site_ids): for site in site_ids: url_only = False url_field_name = "url" - if site_ids[site]: - if "url_field" in site_ids[site]: - url_field_name = site_ids[site]["url_field"] - item_id = site_ids[site]["id"] + site_value = site_ids[site] + if site_value: + if isinstance(site_value, dict): + url_field_name = site_value.get("url_field", "url") + item_id = site_value["id"] else: - item_id = site_ids[site] + item_id = site_value try: item_id = ObjectId(item_id) conditions.append( diff --git a/tests/test_data.py b/tests/test_data.py index adddab6..39423d4 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -104,7 +104,8 @@ def test_pages_no_filter(self): self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test - def test_pages_filter(self): + def test_pages_filter_ids_mode(self): + url_value = 'http://example.com' pages_command = { 'event': 'rpc:request', 'data': { @@ -112,7 +113,7 @@ def test_pages_filter(self): 'jsonrpc': '2.0', 'method': 'subscribe_to_pages', 'params': {'mode': 'ids', - 'site_ids': {1: {'http://example.com': None}} + 'site_ids': {1: {url_value: None}} } }, } @@ -132,9 +133,42 @@ def test_pages_filter(self): break else: self.assertTrue('url' in json_response["data"]) + self.assertTrue(url_value in json_response["data"]["url"]) cnt += 1 self.execute_cancel(ws_client, subs_id, True) + @tornado.testing.gen_test + def test_pages_filter_url_mode(self): + url_value = 'http://example.com' + pages_command = { + 'event': 'rpc:request', + 'data': { + 'id': "test_pages_0", + 'jsonrpc': '2.0', + 'method': 'subscribe_to_pages', + 'params': {'site_ids': {url_value: None} + } + }, + } + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + ws_client.write_message(json.dumps(pages_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) + self.assertNotEqual(subs_id, -1) + cnt = 0 + while cnt < 1: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.assertTrue(False) + break + else: + self.assertTrue('url' in json_response["data"]) + self.assertTrue(url_value in json_response["data"]["url"]) + cnt += 1 + self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test def test_wrong_cancel(self): From 11cfe164ac313c2102534d30f53b876c0449f782 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Fri, 8 Jul 2016 22:30:29 +0500 Subject: [PATCH 27/44] DOC some docs (unfinished) --- docs/json-rpc-api.rst | 67 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index 6da986c..3f95e69 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -44,5 +44,72 @@ JSON-RPC API allows to * start new crawls; * subscribe to job updates. +jobs.subscribe + Get information about jobs and subscribe for new jobs. + + Parameters: + + * last_id - optional, ObjectID value of a last previously seen job. + When passed, only new job data is returned. + * query - optional, MongoDB query + * fields - optional, ... + + +New API +======= + +Working with jobs +----------------- + +Open a websocket connection to ``/ws-jobs-data`` in order to use +jobs JSON-RPC API for scraping jobs. + +subscribe_to_jobs + Get information about jobs and subscribe for new jobs. + Parameters: + + * include - an array of regexes which should match URLs to include; + * exclude - an array of regexes; URLs matched by these regexes are excluded + from the result; + * update_delay - int, a minimum number of ms between websocket mesages + (FIXME). + + Response contains subscription ID in ``['data']['result']['id']`` field:: + + {'data': {'id': '', + 'jsonrpc': '2.0', + 'result': {'datatype': 'job_subscription_id', 'id': '0'}}, + 'event': 'rpc:response'} + + Use this ID to cancel the subscription. + + After the subscription Arachnado will start to send information + about new jobs. Messages look like this:: + + {'data': {'_id': '574718bba7a4edb9b026f248', + 'finished_at': '2016-05-26 16:03:17', + 'id': '97ca610fa8c347dbafeca9fcd02213dd', + 'options': {'args': {}, + 'crawl_id': '97ca610fa8c347dbafeca9fcd02213dd', + 'domain': 'scrapy.org', + 'settings': {}}, + 'spider': 'generic', + 'started_at': '2016-05-26 16:03:16', + 'stats': {...}, + 'status': 'finished'}, + 'event': 'jobs.tailed'} + + +cancel_subscription + Stop receiving updates about jobs. Parameters: + + * subscription_id + + +Working with pages (items) +-------------------------- + + + .. _JSON-RPC: http://www.jsonrpc.org/specification From d388d26ee234e08a84c986610b748ece437e6b05 Mon Sep 17 00:00:00 2001 From: zuev Date: Sun, 10 Jul 2016 21:17:50 +0300 Subject: [PATCH 28/44] Data API update --- arachnado/rpc/data.py | 86 ++++++++++++++++++++++++++----------------- tests/test_data.py | 22 ++++++----- tests/utils.py | 48 +++++++++++++++--------- 3 files changed, 96 insertions(+), 60 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index d8e4910..eb416fc 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -33,8 +33,6 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): def _send_event(self, event, data): message = json_encode({'event': event, 'data': data}) if len(message) < self.max_msg_size: - # logging.info("{}: {}: {}".format(self.cnt, event, len(message))) - # self.cnt += 1 return super(DataRpcWebsocketHandler, self).write_event(event, data) def init_hb(self, update_delay): @@ -46,7 +44,8 @@ def init_hb(self, update_delay): ) self.data_hb.start() - def add_storage_wrapper(self, mongo_q, storage_wrapper): + def add_storage_wrapper(self, mongo_q): + storage_wrapper = self.create_storage_wapper() self.dispatcher.add_object(storage_wrapper) new_id = str(len(self.storages)) self.storages[new_id] = { @@ -98,19 +97,22 @@ def on_spider_closed(self, spider): def send_updates(self): while len(self.stored_data): item = self.stored_data.popleft() - return self._send_event(item["event"], item["data"]) + self._send_event(item["event"], item["data"]) + def create_storage_wapper(self): + return None class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): event_types = ['stats:changed',] mongo_id_mapping = None job_url_mapping = None + stored_jobs_stats = None - def subscribe_to_jobs(self, include=[], exclude=[], update_delay=0): + def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0): mongo_q = self.create_jobs_query(include=include, exclude=exclude) self.init_hb(update_delay) return {"datatype": "job_subscription_id", - "id": self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_jobs_storage_link()) + "id": self.add_storage_wrapper(mongo_q) } @gen.coroutine @@ -150,16 +152,34 @@ def write_event(self, event, data, handler_id=None): except Exception as ex: logger.warning("Invalid stats field in job {}".format(event_data.get("_id", "MISSING MONGO ID"))) if event in self.event_types and self.delay_mode: - self.stored_data.append({"event":event, "data":event_data}) + item_id = event_data.get("_id", None) + if item_id: + if item_id in self.stored_jobs_stats: + self.stored_jobs_stats[item_id]["data"]["stats"].update(event_data["stats"]) + self.stored_jobs_stats[item_id]["data"]["stats_dict"].update(event_data["stats_dict"]) + else: + item = {"event":event, "data":event_data} + self.stored_jobs_stats[item_id] = item + else: + logger.warning("Job data without _id field from event {}".format(event)) else: return self._send_event(event, event_data) + def send_updates(self): + super(JobsDataRpcWebsocketHandler, self).send_updates() + for job_id in set(self.stored_jobs_stats.keys()): + item = self.stored_jobs_stats.pop(job_id, None) + if item: + self._send_event(item["event"], item["data"]) + def create_jobs_query(self, include, exclude): conditions = [] - for inc_str in include: - conditions.append({"urls":{'$regex': '.*' + inc_str + '.*'}}) - for exc_str in exclude: - conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) + if include: + for inc_str in include: + conditions.append({"urls":{'$regex': inc_str }}) + if exclude: + for exc_str in exclude: + conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) jobs_q = {} if len(conditions) == 1: jobs_q = conditions[0] @@ -172,20 +192,21 @@ def initialize(self, *args, **kwargs): self.dispatcher["subscribe_to_jobs"] = self.subscribe_to_jobs self.mongo_id_mapping = {} self.job_url_mapping = {} + self.stored_jobs_stats = {} - def create_jobs_storage_link(self): + def create_storage_wapper(self): jobs = Jobs(self, *self.i_args, **self.i_kwargs) return jobs def on_close(self): - logger.info("connection closed") + logger.debug("connection closed") if self.cp: self.cp.signals.disconnect(self.on_stats_changed, agg_stats_changed) self.cp.signals.disconnect(self.on_spider_closed, CPS.spider_closed) super(JobsDataRpcWebsocketHandler, self).on_close() def open(self): - logger.info("new connection") + logger.debug("new connection") super(JobsDataRpcWebsocketHandler, self).open() if self.cp: self.cp.signals.connect(self.on_stats_changed, agg_stats_changed) @@ -200,26 +221,25 @@ class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): """ pages API""" event_types = ['pages.tailed'] - def subscribe_to_pages(self, site_ids=None, update_delay=0, mode="urls"): + def subscribe_to_pages(self, urls=None, url_groups=None, update_delay=0): self.init_hb(update_delay) result = { "datatype": "pages_subscription_id", "single_subscription_id": "", "id": {}, } - if mode == "urls": - mongo_q = self.create_pages_query(site_ids=site_ids) - result["single_subscription_id"] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) - elif mode == "ids": + if urls: + mongo_q = self.create_pages_query(urls) + result["single_subscription_id"] = self.add_storage_wrapper(mongo_q) + if url_groups: res = {} - if site_ids: - for site_id in site_ids: - mongo_q = self.create_pages_query(site_ids=site_ids[site_id]) - res[site_id] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) - else: - mongo_q = {} - result["single_subscription_id"] = self.add_storage_wrapper(mongo_q, storage_wrapper=self.create_pages_storage_link()) + for group_id in url_groups: + mongo_q = self.create_pages_query(url_groups[group_id]) + res[group_id] = self.add_storage_wrapper(mongo_q) result["id"] = res + if not urls and not url_groups: + mongo_q = {} + result["single_subscription_id"] = self.add_storage_wrapper(mongo_q) return result @gen.coroutine @@ -233,17 +253,17 @@ def initialize(self, *args, **kwargs): super(PagesDataRpcWebsocketHandler, self).initialize(*args, **kwargs) self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages - def create_pages_storage_link(self): + def create_storage_wapper(self): pages = Pages(self, *self.i_args, **self.i_kwargs) return pages - def create_pages_query(self, site_ids): + def create_pages_query(self, url_ids): conditions = [] - if site_ids: - for site in site_ids: + if url_ids: + for site in url_ids: url_only = False url_field_name = "url" - site_value = site_ids[site] + site_value = url_ids[site] if site_value: if isinstance(site_value, dict): url_field_name = site_value.get("url_field", "url") @@ -253,7 +273,7 @@ def create_pages_query(self, site_ids): try: item_id = ObjectId(item_id) conditions.append( - {"$and":[{url_field_name:{"$regex": site + '.*'}}, + {"$and":[{url_field_name:{"$regex": site }}, {"_id":{"$gt":item_id}} ]} ) @@ -264,7 +284,7 @@ def create_pages_query(self, site_ids): url_only = True if url_only: conditions.append( - {url_field_name:{"$regex": site + '.*'}} + {url_field_name:{"$regex": site}} ) items_q = {} if len(conditions) == 1: diff --git a/tests/test_data.py b/tests/test_data.py index 39423d4..0148191 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -3,17 +3,22 @@ import json from tornado import web, websocket import tornado.testing +from tornado.ioloop import TimeoutError import tests.utils as u -class TestJobsAPI(tornado.testing.AsyncHTTPTestCase): +class TestDataAPI(tornado.testing.AsyncHTTPTestCase): pages_uri = r"/ws-pages-data" jobs_uri = r"/ws-jobs-data" - def setUp(self): - tornado.ioloop.IOLoop.current().run_sync(u.init_db) - super(TestJobsAPI, self).setUp() + @classmethod + def setUpClass(cls): + u.init_db() + + @classmethod + def tearDownClass(cls): + u.clear_db() def get_app(self): return u.get_app(self.pages_uri, self.jobs_uri) @@ -104,7 +109,7 @@ def test_pages_no_filter(self): self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test - def test_pages_filter_ids_mode(self): + def test_pages_filter_url_groups(self): url_value = 'http://example.com' pages_command = { 'event': 'rpc:request', @@ -112,8 +117,7 @@ def test_pages_filter_ids_mode(self): 'id': "test_pages_0", 'jsonrpc': '2.0', 'method': 'subscribe_to_pages', - 'params': {'mode': 'ids', - 'site_ids': {1: {url_value: None}} + 'params': {'url_groups': {1: {url_value: None}} } }, } @@ -138,7 +142,7 @@ def test_pages_filter_ids_mode(self): self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test - def test_pages_filter_url_mode(self): + def test_pages_filter_urls(self): url_value = 'http://example.com' pages_command = { 'event': 'rpc:request', @@ -146,7 +150,7 @@ def test_pages_filter_url_mode(self): 'id': "test_pages_0", 'jsonrpc': '2.0', 'method': 'subscribe_to_pages', - 'params': {'site_ids': {url_value: None} + 'params': {'urls': {url_value: None} } }, } diff --git a/tests/utils.py b/tests/utils.py index 2b9db2d..6dca1cd 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,11 +2,19 @@ import os import tornado import json +from pymongo import MongoClient +from pymongo.errors import DuplicateKeyError + from arachnado.rpc.data import PagesDataRpcWebsocketHandler, JobsDataRpcWebsocketHandler from arachnado.storages.mongotail import MongoTailStorage from arachnado.utils.mongo import motor_from_uri +def get_mongo_db(): + client = MongoClient('mongodb://localhost:27017/') + return client["arachnado-test"] + + def get_db_uri(): return "mongodb://localhost:27017/arachnado-test" @@ -29,22 +37,26 @@ def get_app(ws_pages_uri, ws_jobs_uri): return app -@tornado.gen.coroutine -def import_file(file_path, mongo_uri): - _, _, _, _, col = motor_from_uri(mongo_uri) - # col.drop() - with open(file_path, "r") as fin: - for text_line in fin: - record = json.loads(text_line) - yield col.insert(record) - - -@tornado.gen.coroutine +# @tornado.gen.coroutine def init_db(): - db_uri = get_db_uri() - jobs_uri = "{}/jobs".format(db_uri) - jobs_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "jobs.jl") - import_file(jobs_path, jobs_uri) - items_uri = "{}/items".format(db_uri) - items_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "items.jl") - import_file(items_path, items_uri) \ No newline at end of file + db = get_mongo_db() + collections = ["jobs", "items"] + for collection in collections: + col_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "{}.jl".format(collection)) + col = db[collection] + with open(col_path, "r") as fin: + for text_line in fin: + record = json.loads(text_line) + try: + col.insert(record) + except DuplicateKeyError: + pass + + +# @tornado.gen.coroutine +def clear_db(): + db = get_mongo_db() + collections = ["jobs", "items"] + for collection in collections: + col = db[collection] + col.drop() \ No newline at end of file From 78a40f4e8060c96c7289676af7fd33b46b985456 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 13 Jul 2016 17:58:48 +0300 Subject: [PATCH 29/44] Data API and docs update --- arachnado/rpc/data.py | 6 +-- docs/json-rpc-api.rst | 106 ++++++++++++++++++++++++++++++++++++++++-- tests/test_data.py | 21 +++++++++ 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index eb416fc..83acbeb 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -32,7 +32,7 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): def _send_event(self, event, data): message = json_encode({'event': event, 'data': data}) - if len(message) < self.max_msg_size: + if len(message) < self.max_msg_size or not self.max_msg_size: return super(DataRpcWebsocketHandler, self).write_event(event, data) def init_hb(self, update_delay): @@ -66,6 +66,7 @@ def cancel_subscription(self, subscription_id): def set_max_message_size(self, max_size): self.max_msg_size = max_size + return True def initialize(self, *args, **kwargs): self.stored_data = deque() @@ -221,8 +222,7 @@ class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): """ pages API""" event_types = ['pages.tailed'] - def subscribe_to_pages(self, urls=None, url_groups=None, update_delay=0): - self.init_hb(update_delay) + def subscribe_to_pages(self, urls=None, url_groups=None): result = { "datatype": "pages_subscription_id", "single_subscription_id": "", diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index 3f95e69..a3893ef 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -4,7 +4,7 @@ JSON RPC API Arachnado provides JSON-RPC_ API for working with jobs and crawled items (pages). The API works over WebSocket transport. -**FIXME**: JSON-RPC request objects are wrapped: +JSON-RPC request objects are wrapped: ``{"event": "rpc:request", "data": }``. Responses are also wrapped: ``{"event": "rpc:response", "data": }``. @@ -71,8 +71,8 @@ subscribe_to_jobs * include - an array of regexes which should match URLs to include; * exclude - an array of regexes; URLs matched by these regexes are excluded from the result; - * update_delay - int, a minimum number of ms between websocket mesages - (FIXME). + * update_delay - (opional) int, a minimum number of ms between websocket messages. + If this parameter set then Arachnado will aggregate job statistics. Response contains subscription ID in ``['data']['result']['id']`` field:: @@ -99,17 +99,113 @@ subscribe_to_jobs 'status': 'finished'}, 'event': 'jobs.tailed'} - cancel_subscription Stop receiving updates about jobs. Parameters: * subscription_id -Working with pages (items) +set_max_message_size + Set maximum message size in bytes for websockets channel. + Messages larger than specified limit are dropped. + Default value is 2**20. + To disable this chack set max size to zero. + Parameters: + * max_size - an array of regexes which should match URLs to include; + + Response returns result(true/false) at result field:: + + {"event": "rpc:response", + "data": { + "id": "test_set_0", + "result": true, + "jsonrpc": "2.0" + }} + + +Working with pages (crawled items) -------------------------- +Open a websocket connection to ``/ws-pages-data`` in order to use +jobs JSON-RPC API for scraping jobs. + +subscribe_to_pages + Get crawled pages(items) for specific urls. + Allows to get all pages or only crawled since last update. + To get only new pages set last seen page id (from "id" field of page record) for an url. + To get all pages set page id to None. + + Parameters: + + * urls - a dictionary of :. Arachnado will create one subscription id for all urls; + * url_groups - a dictionary of : . Arachnado will create one subscription id for each url group. + + Command example:: + + {'event': 'rpc:request', + 'data': { + 'id': "sample_0", + 'jsonrpc': '2.0', + 'method': 'subscribe_to_pages', + 'params': {'urls': {'http://example.com': None}, + 'url_groups': {'gr1': {'http://example1.com': None}, + 'gr2': {'http://example2.com': "57863974a8cb9c15e8f3d53a"}} + } + }, + } + + Response example:: + + {"event": "rpc:response", + "data": { + "result": { + "datatype": "pages_subscription_id", + "single_subscription_id": "112", + "id": { + "gr1": "113", + "gr2": "114", + }}, + "id": "sample_0", + "jsonrpc": "2.0"} + } + + Use these IDs to cancel subscriptions. + + After the subscription Arachnado will start to send information + about crawled pages. Messages look like this:: + + {"data": { + "status": 200, + "items": [], + "_id": "57863974a8cb9c15e8f3d53a", + "url": "http://example.com/index.php", + "headers": {}, + "_type": "page", + "body": ""}, + "event": "pages.tailed"} + + +cancel_subscription + Stop receiving updates. Parameters: + + * subscription_id + +set_max_message_size + Set maximum message size in bytes for websockets channel. + Messages larger than specified limit are dropped. + Default value is 2**20. + To disable this chack set max size to zero. + Parameters: + * max_size - an array of regexes which should match URLs to include; + + Response returns result(true/false) at result field:: + {"event": "rpc:response", + "data": { + "id": "test_set_0", + "result": true, + "jsonrpc": "2.0" + }} .. _JSON-RPC: http://www.jsonrpc.org/specification diff --git a/tests/test_data.py b/tests/test_data.py index 0148191..8566f10 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -23,6 +23,27 @@ def tearDownClass(cls): def get_app(self): return u.get_app(self.pages_uri, self.jobs_uri) + @tornado.testing.gen_test + def test_set_message_size(self): + test_command = { + 'event': 'rpc:request', + 'data': { + 'id': "test_set_0", + 'jsonrpc': '2.0', + 'method': 'set_max_message_size', + 'params': { + "max_size":100 + }, + }, + } + ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri + ws_client = yield tornado.websocket.websocket_connect(ws_url) + ws_client.write_message(json.dumps(test_command)) + response = yield ws_client.read_message() + json_response = json.loads(response) + res = json_response.get("data", {}).get("result", False) + self.assertTrue(res) + @tornado.testing.gen_test def test_jobs_no_filter(self): jobs_command = { From 4edb9affc17f1a07542ea471f9756c2eb670fc05 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 19 Jul 2016 18:19:55 +0300 Subject: [PATCH 30/44] Data API update by PR comments --- arachnado/__main__.py | 2 ++ arachnado/rpc/data.py | 32 +++++++++++++++++--------------- arachnado/storages/mongo.py | 5 +++++ docs/json-rpc-api.rst | 4 +++- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/arachnado/__main__.py b/arachnado/__main__.py index 6f7d864..318e900 100755 --- a/arachnado/__main__.py +++ b/arachnado/__main__.py @@ -83,8 +83,10 @@ def main(port, host, start_manhole, manhole_port, manhole_host, loglevel, opts): }) job_storage = MongoTailStorage(jobs_uri, cache=True) + job_storage.ensure_index("urls") site_storage = MongoStorage(sites_uri, cache=True) item_storage = MongoTailStorage(items_uri) + item_storage.ensure_index("url") crawler_process = ArachnadoCrawlerProcess(settings) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 83acbeb..e3e8599 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -24,7 +24,7 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): delay_mode = False # static variable, same for all instances event_types = [] - data_hb = None + heartbeat_data = None i_args = None i_kwargs = None storages = None @@ -32,17 +32,20 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): def _send_event(self, event, data): message = json_encode({'event': event, 'data': data}) + # if message size is higher then ws connection can be dropped without proper message if len(message) < self.max_msg_size or not self.max_msg_size: return super(DataRpcWebsocketHandler, self).write_event(event, data) + else: + logger.info("Message from {} event size exceeded. Message wasn't sent.".format(event)) - def init_hb(self, update_delay): - if update_delay > 0 and not self.data_hb: + def init_heartbeat(self, update_delay): + if update_delay > 0 and not self.heartbeat_data: self.delay_mode = True - self.data_hb = tornado.ioloop.PeriodicCallback( + self.heartbeat_data = tornado.ioloop.PeriodicCallback( lambda: self.send_updates(), update_delay ) - self.data_hb.start() + self.heartbeat_data.start() def add_storage_wrapper(self, mongo_q): storage_wrapper = self.create_storage_wapper() @@ -82,8 +85,8 @@ def on_close(self): logger.info("connection closed") for storage in self.storages.values(): storage["storage"]._on_close() - if self.data_hb: - self.data_hb.stop() + if self.heartbeat_data: + self.heartbeat_data.stop() super(DataRpcWebsocketHandler, self).on_close() def open(self): @@ -109,9 +112,9 @@ class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): job_url_mapping = None stored_jobs_stats = None - def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0): - mongo_q = self.create_jobs_query(include=include, exclude=exclude) - self.init_hb(update_delay) + def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, last_id=None): + mongo_q = self.create_jobs_query(include=include, exclude=exclude, last_id=last_id) + self.init_heartbeat(update_delay) return {"datatype": "job_subscription_id", "id": self.add_storage_wrapper(mongo_q) } @@ -128,10 +131,7 @@ def write_event(self, event, data, handler_id=None): if event == 'stats:changed': if len(data) > 1: job_id = data[0] - # two fields with same content for back compatibility - event_data = {"stats": data[1], - "stats_dict": data[1], - } + event_data = {"stats": data[1]} # same as crawl_id event_data["id"] = job_id # mongo id @@ -173,8 +173,10 @@ def send_updates(self): if item: self._send_event(item["event"], item["data"]) - def create_jobs_query(self, include, exclude): + def create_jobs_query(self, include, exclude, last_id): conditions = [] + if last_id: + conditions.append({"_id":{"$gt":last_id}}) if include: for inc_str in include: conditions.append({"urls":{'$regex': inc_str }}) diff --git a/arachnado/storages/mongo.py b/arachnado/storages/mongo.py index c71ed93..a253365 100644 --- a/arachnado/storages/mongo.py +++ b/arachnado/storages/mongo.py @@ -97,6 +97,11 @@ def create(self, doc): self.signal_manager.send_catch_log(self.signals['created'], data=doc) raise Return(result) + @coroutine + def ensure_index(self, key_or_list): + result = yield self.col.ensure_index(key_or_list) + raise Return(result) + @coroutine def update(self, doc): doc = replace_dots(doc) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index a3893ef..44ffb2e 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -71,7 +71,9 @@ subscribe_to_jobs * include - an array of regexes which should match URLs to include; * exclude - an array of regexes; URLs matched by these regexes are excluded from the result; - * update_delay - (opional) int, a minimum number of ms between websocket messages. + * update_delay - (opional) int, a minimum number of ms between websocket messages; + * last_id - optional, ObjectID value of a last previously seen job. + When passed, only new job data is returned. If this parameter set then Arachnado will aggregate job statistics. Response contains subscription ID in ``['data']['result']['id']`` field:: From c323d8b356c51c88954311fe5225899a9c821f82 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 20 Jul 2016 17:42:38 +0300 Subject: [PATCH 31/44] Pages search by job urls --- arachnado/__main__.py | 1 + arachnado/rpc/data.py | 157 ++++++++++++++++++++++++++++------------- arachnado/rpc/jobs.py | 13 ++-- arachnado/rpc/pages.py | 3 + docs/json-rpc-api.rst | 2 +- tests/items.jl | 2 +- tests/test_data.py | 4 +- 7 files changed, 125 insertions(+), 57 deletions(-) diff --git a/arachnado/__main__.py b/arachnado/__main__.py index 318e900..80549a0 100755 --- a/arachnado/__main__.py +++ b/arachnado/__main__.py @@ -87,6 +87,7 @@ def main(port, host, start_manhole, manhole_port, manhole_host, loglevel, opts): site_storage = MongoStorage(sites_uri, cache=True) item_storage = MongoTailStorage(items_uri) item_storage.ensure_index("url") + item_storage.ensure_index(settings.get('MONGO_EXPORT_JOBID_KEY')) crawler_process = ArachnadoCrawlerProcess(settings) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index e3e8599..e6a07da 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -106,24 +106,25 @@ def send_updates(self): def create_storage_wapper(self): return None + class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): event_types = ['stats:changed',] mongo_id_mapping = None job_url_mapping = None stored_jobs_stats = None - def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, last_id=None): - mongo_q = self.create_jobs_query(include=include, exclude=exclude, last_id=last_id) + def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, id=None): + mongo_q = self.create_jobs_query(include=include, exclude=exclude, last_id=id) self.init_heartbeat(update_delay) return {"datatype": "job_subscription_id", "id": self.add_storage_wrapper(mongo_q) } @gen.coroutine - def write_event(self, event, data, handler_id=None): + def write_event(self, event, data, callback_meta=None): event_data = data - if event == 'jobs.tailed' and "id" in data and handler_id: - self.storages[handler_id]["job_ids"].add(data["id"]) + if event == 'jobs.tailed' and "id" in data and callback_meta: + self.storages[callback_meta]["job_ids"].add(data["id"]) self.mongo_id_mapping[data["id"]] = data.get("_id", None) self.job_url_mapping[data["id"]] = data.get("urls", None) if event in ['stats:changed', 'jobs:state']: @@ -224,6 +225,7 @@ class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): """ pages API""" event_types = ['pages.tailed'] + @gen.coroutine def subscribe_to_pages(self, urls=None, url_groups=None): result = { "datatype": "pages_subscription_id", @@ -231,21 +233,46 @@ def subscribe_to_pages(self, urls=None, url_groups=None): "id": {}, } if urls: - mongo_q = self.create_pages_query(urls) - result["single_subscription_id"] = self.add_storage_wrapper(mongo_q) + result["single_subscription_id"] = yield self.create_subscribtion_to_urls(urls) if url_groups: res = {} for group_id in url_groups: - mongo_q = self.create_pages_query(url_groups[group_id]) - res[group_id] = self.add_storage_wrapper(mongo_q) + res[group_id] = yield self.create_subscribtion_to_urls(url_groups[group_id]) result["id"] = res if not urls and not url_groups: - mongo_q = {} - result["single_subscription_id"] = self.add_storage_wrapper(mongo_q) - return result + stor_id, storage = self.add_storage() + result["single_subscription_id"] = stor_id + storage["pages"].subscribe(query={}) + raise gen.Return(result) + + @gen.coroutine + def create_subscribtion_to_urls(self, urls): + jobs_to_subscribe = [] + stor_id, storage = self.add_storage() + result = stor_id + for url in urls: + last_id = urls[url] + jobs = Jobs(self, *self.i_args, **self.i_kwargs) + jobs.callback_meta = { + "subscription_id":stor_id, + "last_id":last_id + } + jobs.callback = self.job_query_callback + jobs_q = self.create_jobs_query(url) + jobs_ds = yield jobs.storage.fetch(jobs_q) + job_ids =[x["_id"] for x in jobs_ds] + storage["job_ids"].update(job_ids) + pages_query = self.create_pages_query(job_ids, last_id) + storage["filters"].append(pages_query) + jobs_to_subscribe.append([jobs_q, jobs]) + if storage["filters"]: + storage["pages"].subscribe(query={"$or": storage["filters"]}) + for jobs_q, jobs in jobs_to_subscribe: + jobs.subscribe(query=jobs_q) + raise gen.Return(result) @gen.coroutine - def write_event(self, event, data, handler_id=None): + def write_event(self, event, data): if event in self.event_types and self.delay_mode: self.stored_data.append({"event":event, "data":data}) else: @@ -253,44 +280,76 @@ def write_event(self, event, data, handler_id=None): def initialize(self, *args, **kwargs): super(PagesDataRpcWebsocketHandler, self).initialize(*args, **kwargs) + # key is a subscription id + # contains jobs storage, pages storage and set of job ids for pages subscription self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages - def create_storage_wapper(self): - pages = Pages(self, *self.i_args, **self.i_kwargs) - return pages + @gen.coroutine + def job_query_callback(self, event, data, callback_meta=None): + if event == 'jobs.tailed' and "_id" in data and callback_meta: + storage = self.storages[callback_meta["subscription_id"]] + job_id = data["_id"] + if job_id not in storage["job_ids"]: + # stop pages subscription + storage["pages"].unsubscribe() + # create new pages query + pages_query = self.create_pages_query([job_id], callback_meta["last_id"]) + storage["filters"].append(pages_query) + # subscribe to pages + storage["pages"].subscribe(query={"$or": storage["filters"]}) + else: + logger.debug("Already subscribed to job {}".format(job_id)) + else: + logger.warning("Jobs callback without with incomplete data") - def create_pages_query(self, url_ids): - conditions = [] - if url_ids: - for site in url_ids: - url_only = False - url_field_name = "url" - site_value = url_ids[site] - if site_value: - if isinstance(site_value, dict): - url_field_name = site_value.get("url_field", "url") - item_id = site_value["id"] - else: - item_id = site_value - try: - item_id = ObjectId(item_id) - conditions.append( - {"$and":[{url_field_name:{"$regex": site }}, - {"_id":{"$gt":item_id}} - ]} - ) - except InvalidId: - logger.warning("Invlaid ObjectID: {}, will use url condition only.".format(item_id)) - url_only = True - else: - url_only = True - if url_only: - conditions.append( - {url_field_name:{"$regex": site}} - ) + def create_jobs_query(self, url): + if url: + return {"urls":{'$regex': url }} + else: + return {} + + def create_pages_query(self, job_ids=None, last_id=None): + filters = [] + job_conditions_lst = [] + if job_ids: + for job_id in job_ids: + job_conditions_lst.append({"_job_id":{'$eq': job_id }}) + if job_conditions_lst: + if len(job_conditions_lst) > 1: + filters.append({"$or": job_conditions_lst}) + else: + filters.append(job_conditions_lst[0]) + if last_id: + try: + page_id = ObjectId(last_id) + filters.append({"_id":{"$gt":page_id}}) + except InvalidId: + logger.warning("Invalid ObjectID: {}, will use job ids filter only.".format(last_id)) items_q = {} - if len(conditions) == 1: - items_q = conditions[0] - elif len(conditions): - items_q = {"$or": conditions} + if len(filters) == 1: + items_q = filters[0] + elif len(filters): + items_q = {"$and": filters} return items_q + + def add_storage(self): + pages = Pages(self, *self.i_args, **self.i_kwargs) + self.dispatcher.add_object(pages) + new_id = str(len(self.storages)) + self.storages[new_id] = { + "pages": pages, + "jobs": [], + "job_ids": set([]), + "filters": [], + } + return new_id, self.storages[new_id] + + def cancel_subscription(self, subscription_id): + storage = self.storages.pop(subscription_id, None) + if storage: + for jobs in storage["jobs"]: + jobs._on_close() + storage["pages"]._on_close() + return True + else: + return False \ No newline at end of file diff --git a/arachnado/rpc/jobs.py b/arachnado/rpc/jobs.py index e0c2f40..5b30381 100644 --- a/arachnado/rpc/jobs.py +++ b/arachnado/rpc/jobs.py @@ -8,7 +8,8 @@ class Jobs(object): This object is exposed for RPC requests. It allows to subscribe for scraping job updates. """ - handler_id = None + callback_meta = None + callback = None logger = logging.getLogger(__name__) def __init__(self, handler, job_storage, **kwargs): @@ -24,8 +25,12 @@ def _on_close(self): self.storage.unsubscribe('tailed') def _publish(self, data): + if self.callback: + _callback = self.callback + else: + _callback = self.handler.write_event if self.storage.tailing: - if self.handler_id: - self.handler.write_event('jobs.tailed', data, handler_id=self.handler_id) + if self.callback_meta: + _callback('jobs.tailed', data, callback_meta=self.callback_meta) else: - self.handler.write_event('jobs.tailed', data) + _callback('jobs.tailed', data) diff --git a/arachnado/rpc/pages.py b/arachnado/rpc/pages.py index 4540200..682d9ea 100644 --- a/arachnado/rpc/pages.py +++ b/arachnado/rpc/pages.py @@ -19,6 +19,9 @@ def subscribe(self, last_id=0, query=None, fields=None, fetch_delay=None): def _on_close(self): self.storage.unsubscribe('tailed') + def unsubscribe(self): + self.storage.unsubscribe('tailed') + def _publish(self, data): if self.storage.tailing: self.handler.write_event('pages.tailed', data) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index 44ffb2e..23c20e8 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -72,7 +72,7 @@ subscribe_to_jobs * exclude - an array of regexes; URLs matched by these regexes are excluded from the result; * update_delay - (opional) int, a minimum number of ms between websocket messages; - * last_id - optional, ObjectID value of a last previously seen job. + * id - optional, ObjectID value of a last previously seen job. When passed, only new job data is returned. If this parameter set then Arachnado will aggregate job statistics. diff --git a/tests/items.jl b/tests/items.jl index f3df1f7..54029f6 100644 --- a/tests/items.jl +++ b/tests/items.jl @@ -1 +1 @@ -{"status" : 200, "body" : "", "_type" : "page", "url" : "http://example.com/index.php", "items" : [ ], "headers" : { "Cache-Control" : [ "private, no-cache=\"set-cookie\"" ], "X-Powered-By" : [ "PHP/5.5.9-1ubuntu4.14" ], "Date" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Content-Type" : [ "text/html; charset=UTF-8" ], "Expires" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Vary" : [ "Accept-Encoding" ], "Server" : [ "Apache/2.4.7 (Ubuntu)" ] }, "meta" : { "download_timeout" : 180, "depth" : 2}} \ No newline at end of file +{"_job_id": "5749d89da8cb9c1f286e3a90","status" : 200, "body" : "", "_type" : "page", "url" : "http://example.com/index.php", "items" : [ ], "headers" : { "Cache-Control" : [ "private, no-cache=\"set-cookie\"" ], "X-Powered-By" : [ "PHP/5.5.9-1ubuntu4.14" ], "Date" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Content-Type" : [ "text/html; charset=UTF-8" ], "Expires" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Vary" : [ "Accept-Encoding" ], "Server" : [ "Apache/2.4.7 (Ubuntu)" ] }, "meta" : { "download_timeout" : 180, "depth" : 2}} \ No newline at end of file diff --git a/tests/test_data.py b/tests/test_data.py index 8566f10..ce8f6b5 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -135,7 +135,7 @@ def test_pages_filter_url_groups(self): pages_command = { 'event': 'rpc:request', 'data': { - 'id': "test_pages_0", + 'id': "test_pages_1", 'jsonrpc': '2.0', 'method': 'subscribe_to_pages', 'params': {'url_groups': {1: {url_value: None}} @@ -168,7 +168,7 @@ def test_pages_filter_urls(self): pages_command = { 'event': 'rpc:request', 'data': { - 'id': "test_pages_0", + 'id': "test_pages_2", 'jsonrpc': '2.0', 'method': 'subscribe_to_pages', 'params': {'urls': {url_value: None} From c37667289cc586e2e003e7e2587447af0a19f26d Mon Sep 17 00:00:00 2001 From: zuev Date: Fri, 22 Jul 2016 17:17:03 +0300 Subject: [PATCH 32/44] Data API update --- arachnado/__main__.py | 4 +- arachnado/rpc/data.py | 192 ++++++++++++++++++++++++------------------ tests/utils.py | 2 - 3 files changed, 115 insertions(+), 83 deletions(-) diff --git a/arachnado/__main__.py b/arachnado/__main__.py index 80549a0..bdfe082 100755 --- a/arachnado/__main__.py +++ b/arachnado/__main__.py @@ -87,7 +87,9 @@ def main(port, host, start_manhole, manhole_port, manhole_host, loglevel, opts): site_storage = MongoStorage(sites_uri, cache=True) item_storage = MongoTailStorage(items_uri) item_storage.ensure_index("url") - item_storage.ensure_index(settings.get('MONGO_EXPORT_JOBID_KEY')) + print(settings.get('MONGO_EXPORT_JOBID_KEY')) + item_storage.ensure_index("_job_id") + # item_storage.ensure_index(settings.get('MONGO_EXPORT_JOBID_KEY')) crawler_process = ArachnadoCrawlerProcess(settings) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index e6a07da..59e4eea 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -47,22 +47,22 @@ def init_heartbeat(self, update_delay): ) self.heartbeat_data.start() - def add_storage_wrapper(self, mongo_q): - storage_wrapper = self.create_storage_wapper() - self.dispatcher.add_object(storage_wrapper) - new_id = str(len(self.storages)) - self.storages[new_id] = { - "storage": storage_wrapper, - "job_ids": set([]) - } - storage_wrapper.handler_id = new_id - storage_wrapper.subscribe(query=mongo_q) - return new_id + # def add_storage_wrapper(self, mongo_q): + # storage_wrapper = self.create_storage_wapper() + # self.dispatcher.add_object(storage_wrapper) + # new_id = str(len(self.storages)) + # self.storages[new_id] = { + # "storage": storage_wrapper, + # "job_ids": set([]) + # } + # storage_wrapper.handler_id = new_id + # storage_wrapper.subscribe(query=mongo_q) + # return new_id def cancel_subscription(self, subscription_id): storage = self.storages.pop(subscription_id, None) if storage: - storage._on_close() + storage.on_close() return True else: return False @@ -84,7 +84,7 @@ def initialize(self, *args, **kwargs): def on_close(self): logger.info("connection closed") for storage in self.storages.values(): - storage["storage"]._on_close() + storage.on_close() if self.heartbeat_data: self.heartbeat_data.stop() super(DataRpcWebsocketHandler, self).on_close() @@ -113,18 +113,29 @@ class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): job_url_mapping = None stored_jobs_stats = None - def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, id=None): - mongo_q = self.create_jobs_query(include=include, exclude=exclude, last_id=id) + @gen.coroutine + def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, last_job_id=None): self.init_heartbeat(update_delay) + stor_id, storage = self.add_storage() + jobs_storage = Jobs(self, *self.i_args, **self.i_kwargs) + jobs = yield jobs_storage.storage.fetch(query={}) + jobs_storage.callback_meta = stor_id + # jobs_storage.handler_id = stor_id + storage.add_jobs_subscription(jobs_storage, include=include, exclude=exclude, last_id=last_job_id) return {"datatype": "job_subscription_id", - "id": self.add_storage_wrapper(mongo_q) - } + "id": stor_id} + + def add_storage(self): + new_id = str(len(self.storages)) + storage = DataSubscription() + self.storages[new_id] = storage + return new_id, storage @gen.coroutine def write_event(self, event, data, callback_meta=None): event_data = data if event == 'jobs.tailed' and "id" in data and callback_meta: - self.storages[callback_meta]["job_ids"].add(data["id"]) + self.storages[callback_meta].job_ids.add(data["id"]) self.mongo_id_mapping[data["id"]] = data.get("_id", None) self.job_url_mapping[data["id"]] = data.get("urls", None) if event in ['stats:changed', 'jobs:state']: @@ -144,7 +155,7 @@ def write_event(self, event, data, callback_meta=None): allowed = False if job_id: for storage in self.storages.values(): - allowed = allowed or job_id in storage["job_ids"] + allowed = allowed or job_id in storage.job_ids if not allowed: return if 'stats' in event_data: @@ -158,7 +169,6 @@ def write_event(self, event, data, callback_meta=None): if item_id: if item_id in self.stored_jobs_stats: self.stored_jobs_stats[item_id]["data"]["stats"].update(event_data["stats"]) - self.stored_jobs_stats[item_id]["data"]["stats_dict"].update(event_data["stats_dict"]) else: item = {"event":event, "data":event_data} self.stored_jobs_stats[item_id] = item @@ -174,23 +184,6 @@ def send_updates(self): if item: self._send_event(item["event"], item["data"]) - def create_jobs_query(self, include, exclude, last_id): - conditions = [] - if last_id: - conditions.append({"_id":{"$gt":last_id}}) - if include: - for inc_str in include: - conditions.append({"urls":{'$regex': inc_str }}) - if exclude: - for exc_str in exclude: - conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) - jobs_q = {} - if len(conditions) == 1: - jobs_q = conditions[0] - elif len(conditions): - jobs_q = {"$and": conditions } - return jobs_q - def initialize(self, *args, **kwargs): super(JobsDataRpcWebsocketHandler, self).initialize(*args, **kwargs) self.dispatcher["subscribe_to_jobs"] = self.subscribe_to_jobs @@ -198,10 +191,6 @@ def initialize(self, *args, **kwargs): self.job_url_mapping = {} self.stored_jobs_stats = {} - def create_storage_wapper(self): - jobs = Jobs(self, *self.i_args, **self.i_kwargs) - return jobs - def on_close(self): logger.debug("connection closed") if self.cp: @@ -242,7 +231,7 @@ def subscribe_to_pages(self, urls=None, url_groups=None): if not urls and not url_groups: stor_id, storage = self.add_storage() result["single_subscription_id"] = stor_id - storage["pages"].subscribe(query={}) + storage.pages.subscribe(query={}) raise gen.Return(result) @gen.coroutine @@ -261,12 +250,12 @@ def create_subscribtion_to_urls(self, urls): jobs_q = self.create_jobs_query(url) jobs_ds = yield jobs.storage.fetch(jobs_q) job_ids =[x["_id"] for x in jobs_ds] - storage["job_ids"].update(job_ids) - pages_query = self.create_pages_query(job_ids, last_id) - storage["filters"].append(pages_query) + storage.job_ids.update(job_ids) + pages_query = storage.create_pages_query(job_ids, last_id) + storage.filters.append(pages_query) + storage.jobs.append(jobs) jobs_to_subscribe.append([jobs_q, jobs]) - if storage["filters"]: - storage["pages"].subscribe(query={"$or": storage["filters"]}) + storage.subscribe_to_pages() for jobs_q, jobs in jobs_to_subscribe: jobs.subscribe(query=jobs_q) raise gen.Return(result) @@ -289,16 +278,7 @@ def job_query_callback(self, event, data, callback_meta=None): if event == 'jobs.tailed' and "_id" in data and callback_meta: storage = self.storages[callback_meta["subscription_id"]] job_id = data["_id"] - if job_id not in storage["job_ids"]: - # stop pages subscription - storage["pages"].unsubscribe() - # create new pages query - pages_query = self.create_pages_query([job_id], callback_meta["last_id"]) - storage["filters"].append(pages_query) - # subscribe to pages - storage["pages"].subscribe(query={"$or": storage["filters"]}) - else: - logger.debug("Already subscribed to job {}".format(job_id)) + storage.update_pages_subscription(job_id, callback_meta["last_id"]) else: logger.warning("Jobs callback without with incomplete data") @@ -308,12 +288,69 @@ def create_jobs_query(self, url): else: return {} + def add_storage(self): + new_id = str(len(self.storages)) + pages = Pages(self, *self.i_args, **self.i_kwargs) + self.storages[new_id] = DataSubscription(pages) + return new_id, self.storages[new_id] + + def cancel_subscription(self, subscription_id): + storage = self.storages.pop(subscription_id, None) + if storage: + storage.on_close() + return True + else: + return False + + +class DataSubscription(object): + + def __init__(self, pages_storage=None): + self.pages = pages_storage + self.jobs = [] + self.job_ids = set([]) + self.filters = [] + + def on_close(self): + for jobs in self.jobs: + jobs._on_close() + if self.pages: + self.pages._on_close() + + def subscribe_to_pages(self, require_filters=True): + if self.filters: + if len(self.filters) == 1: + self.pages.subscribe(query=self.filters[0]) + elif len(self.filters) > 1: + self.pages.subscribe(query={"$or": self.filters}) + elif not require_filters: + self.pages.subscribe(query={}) + else: + logger.warning("No subscription - empty filter list") + + def add_jobs_subscription(self, jobs_storage, include=None, exclude=None, last_id=None): + jobs_query = self.create_jobs_subscription_query(include=include, exclude=exclude, last_id=last_id) + self.jobs.append(jobs_storage) + jobs_storage.subscribe(query=jobs_query) + + def update_pages_subscription(self, job_id, last_id): + if job_id not in self.job_ids: + # stop pages subscription + self.pages.unsubscribe() + # create new pages query + pages_query = self.create_pages_query([job_id], last_id) + self.filters.append(pages_query) + # subscribe to pages + self.subscribe_to_pages() + else: + logger.debug("Already subscribed to job {}".format(job_id)) + def create_pages_query(self, job_ids=None, last_id=None): filters = [] job_conditions_lst = [] if job_ids: for job_id in job_ids: - job_conditions_lst.append({"_job_id":{'$eq': job_id }}) + job_conditions_lst.append({"_job_id":{'$eq': str(job_id) }}) if job_conditions_lst: if len(job_conditions_lst) > 1: filters.append({"$or": job_conditions_lst}) @@ -328,28 +365,23 @@ def create_pages_query(self, job_ids=None, last_id=None): items_q = {} if len(filters) == 1: items_q = filters[0] - elif len(filters): + elif len(filters) > 1: items_q = {"$and": filters} return items_q - def add_storage(self): - pages = Pages(self, *self.i_args, **self.i_kwargs) - self.dispatcher.add_object(pages) - new_id = str(len(self.storages)) - self.storages[new_id] = { - "pages": pages, - "jobs": [], - "job_ids": set([]), - "filters": [], - } - return new_id, self.storages[new_id] - - def cancel_subscription(self, subscription_id): - storage = self.storages.pop(subscription_id, None) - if storage: - for jobs in storage["jobs"]: - jobs._on_close() - storage["pages"]._on_close() - return True - else: - return False \ No newline at end of file + def create_jobs_subscription_query(self, include, exclude, last_id): + conditions = [] + if last_id: + conditions.append({"_id":{"$gt":last_id}}) + if include: + for inc_str in include: + conditions.append({"urls":{'$regex': inc_str }}) + if exclude: + for exc_str in exclude: + conditions.append({"urls":{'$regex': '^((?!' + exc_str + ').)*$'}}) + jobs_q = {} + if len(conditions) == 1: + jobs_q = conditions[0] + elif len(conditions): + jobs_q = {"$and": conditions } + return jobs_q \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py index 6dca1cd..578c6b5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -37,7 +37,6 @@ def get_app(ws_pages_uri, ws_jobs_uri): return app -# @tornado.gen.coroutine def init_db(): db = get_mongo_db() collections = ["jobs", "items"] @@ -53,7 +52,6 @@ def init_db(): pass -# @tornado.gen.coroutine def clear_db(): db = get_mongo_db() collections = ["jobs", "items"] From 45df5d4fba78bf921f6ebb05a8477e56369a136c Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 26 Jul 2016 18:52:31 +0300 Subject: [PATCH 33/44] code cleanup --- arachnado/__main__.py | 2 -- arachnado/rpc/data.py | 17 +---------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/arachnado/__main__.py b/arachnado/__main__.py index bdfe082..3738cc3 100755 --- a/arachnado/__main__.py +++ b/arachnado/__main__.py @@ -87,9 +87,7 @@ def main(port, host, start_manhole, manhole_port, manhole_host, loglevel, opts): site_storage = MongoStorage(sites_uri, cache=True) item_storage = MongoTailStorage(items_uri) item_storage.ensure_index("url") - print(settings.get('MONGO_EXPORT_JOBID_KEY')) item_storage.ensure_index("_job_id") - # item_storage.ensure_index(settings.get('MONGO_EXPORT_JOBID_KEY')) crawler_process = ArachnadoCrawlerProcess(settings) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 59e4eea..80bda2d 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -47,18 +47,6 @@ def init_heartbeat(self, update_delay): ) self.heartbeat_data.start() - # def add_storage_wrapper(self, mongo_q): - # storage_wrapper = self.create_storage_wapper() - # self.dispatcher.add_object(storage_wrapper) - # new_id = str(len(self.storages)) - # self.storages[new_id] = { - # "storage": storage_wrapper, - # "job_ids": set([]) - # } - # storage_wrapper.handler_id = new_id - # storage_wrapper.subscribe(query=mongo_q) - # return new_id - def cancel_subscription(self, subscription_id): storage = self.storages.pop(subscription_id, None) if storage: @@ -120,7 +108,6 @@ def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, last_job jobs_storage = Jobs(self, *self.i_args, **self.i_kwargs) jobs = yield jobs_storage.storage.fetch(query={}) jobs_storage.callback_meta = stor_id - # jobs_storage.handler_id = stor_id storage.add_jobs_subscription(jobs_storage, include=include, exclude=exclude, last_id=last_job_id) return {"datatype": "job_subscription_id", "id": stor_id} @@ -269,8 +256,6 @@ def write_event(self, event, data): def initialize(self, *args, **kwargs): super(PagesDataRpcWebsocketHandler, self).initialize(*args, **kwargs) - # key is a subscription id - # contains jobs storage, pages storage and set of job ids for pages subscription self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages @gen.coroutine @@ -280,7 +265,7 @@ def job_query_callback(self, event, data, callback_meta=None): job_id = data["_id"] storage.update_pages_subscription(job_id, callback_meta["last_id"]) else: - logger.warning("Jobs callback without with incomplete data") + logger.warning("Jobs callback with incomplete data") def create_jobs_query(self, url): if url: From 66ef047f3175b7063b7feca4df697b5a18ea8a50 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 26 Jul 2016 20:32:35 +0300 Subject: [PATCH 34/44] Data API rpc format simplified --- arachnado/rpc/data.py | 99 ++++++++++---------- arachnado/rpc/ws.py | 14 ++- docs/json-rpc-api.rst | 16 ++-- tests/test_data.py | 205 +++++++++++++----------------------------- 4 files changed, 125 insertions(+), 209 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 80bda2d..babf44d 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -23,20 +23,20 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): stored_data = None delay_mode = False # static variable, same for all instances - event_types = [] + # event_types = [] heartbeat_data = None i_args = None i_kwargs = None storages = None max_msg_size = 2**20 - def _send_event(self, event, data): - message = json_encode({'event': event, 'data': data}) + def _send_event(self, data): + message = json_encode(data) # if message size is higher then ws connection can be dropped without proper message if len(message) < self.max_msg_size or not self.max_msg_size: - return super(DataRpcWebsocketHandler, self).write_event(event, data) + return super(DataRpcWebsocketHandler, self).write_event(data) else: - logger.info("Message from {} event size exceeded. Message wasn't sent.".format(event)) + logger.info("Message size exceeded. Message wasn't sent.") def init_heartbeat(self, update_delay): if update_delay > 0 and not self.heartbeat_data: @@ -81,22 +81,13 @@ def open(self): logger.info("new connection") super(DataRpcWebsocketHandler, self).open() - def on_spider_closed(self, spider): - if self.cp: - for job in self.cp.jobs: - self.write_event("jobs:state", job) - def send_updates(self): while len(self.stored_data): item = self.stored_data.popleft() - self._send_event(item["event"], item["data"]) - - def create_storage_wapper(self): - return None + self._send_event(item) class JobsDataRpcWebsocketHandler(DataRpcWebsocketHandler): - event_types = ['stats:changed',] mongo_id_mapping = None job_url_mapping = None stored_jobs_stats = None @@ -108,6 +99,8 @@ def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, last_job jobs_storage = Jobs(self, *self.i_args, **self.i_kwargs) jobs = yield jobs_storage.storage.fetch(query={}) jobs_storage.callback_meta = stor_id + # TODO: set jobs callback here + jobs_storage.callback = self.on_jobs_tailed storage.add_jobs_subscription(jobs_storage, include=include, exclude=exclude, last_id=last_job_id) return {"datatype": "job_subscription_id", "id": stor_id} @@ -119,50 +112,26 @@ def add_storage(self): return new_id, storage @gen.coroutine - def write_event(self, event, data, callback_meta=None): - event_data = data - if event == 'jobs.tailed' and "id" in data and callback_meta: - self.storages[callback_meta].job_ids.add(data["id"]) - self.mongo_id_mapping[data["id"]] = data.get("_id", None) - self.job_url_mapping[data["id"]] = data.get("urls", None) - if event in ['stats:changed', 'jobs:state']: - job_id = None - if event == 'stats:changed': - if len(data) > 1: - job_id = data[0] - event_data = {"stats": data[1]} - # same as crawl_id - event_data["id"] = job_id - # mongo id - event_data["_id"] = self.mongo_id_mapping.get(job_id, "") - # job url - event_data["urls"] = self.job_url_mapping.get(job_id, "") - else: - job_id = data["id"] - allowed = False - if job_id: - for storage in self.storages.values(): - allowed = allowed or job_id in storage.job_ids - if not allowed: - return + def write_event(self, data, aggregate=False): + event_data = dict(data) if 'stats' in event_data: if not isinstance(event_data['stats'], dict): try: event_data['stats'] = json.loads(event_data['stats']) except Exception as ex: logger.warning("Invalid stats field in job {}".format(event_data.get("_id", "MISSING MONGO ID"))) - if event in self.event_types and self.delay_mode: + if aggregate and self.delay_mode: item_id = event_data.get("_id", None) if item_id: if item_id in self.stored_jobs_stats: self.stored_jobs_stats[item_id]["data"]["stats"].update(event_data["stats"]) else: - item = {"event":event, "data":event_data} + item = event_data self.stored_jobs_stats[item_id] = item else: logger.warning("Job data without _id field from event {}".format(event)) else: - return self._send_event(event, event_data) + return self._send_event(event_data) def send_updates(self): super(JobsDataRpcWebsocketHandler, self).send_updates() @@ -193,13 +162,41 @@ def open(self): self.cp.signals.connect(self.on_spider_closed, CPS.spider_closed) def on_stats_changed(self, changes, crawler): - crawl_id = crawler.spider.crawl_id - self.write_event("stats:changed", [crawl_id, changes]) + job_id = crawler.spider.crawl_id + data = {"stats": changes} + # same as crawl_id + data["id"] = job_id + # mongo id + data["_id"] = self.mongo_id_mapping.get(job_id, "") + # job url + data["urls"] = self.job_url_mapping.get(job_id, "") + allowed = False + for storage in self.storages.values(): + allowed = allowed or job_id in storage.job_ids + if allowed: + self.write_event(data, aggregate=True) + + def on_spider_closed(self, spider): + if self.cp: + for job in self.cp.jobs: + job_id = job["id"] + allowed = False + if job_id: + for storage in self.storages.values(): + allowed = allowed or job_id in storage.job_ids + if allowed: + self.write_event(job) + + def on_jobs_tailed(self, event, data, callback_meta=None): + if event == 'jobs.tailed' and "id" in data and callback_meta: + self.storages[callback_meta].job_ids.add(data["id"]) + self.mongo_id_mapping[data["id"]] = data.get("_id", None) + self.job_url_mapping[data["id"]] = data.get("urls", None) + self.write_event(data) class PagesDataRpcWebsocketHandler(DataRpcWebsocketHandler): """ pages API""" - event_types = ['pages.tailed'] @gen.coroutine def subscribe_to_pages(self, urls=None, url_groups=None): @@ -248,11 +245,11 @@ def create_subscribtion_to_urls(self, urls): raise gen.Return(result) @gen.coroutine - def write_event(self, event, data): - if event in self.event_types and self.delay_mode: - self.stored_data.append({"event":event, "data":data}) + def write_event(self, data, aggregate=False): + if aggregate and self.delay_mode: + self.stored_data.append(data) else: - return self._send_event(event, data) + return self._send_event(data) def initialize(self, *args, **kwargs): super(PagesDataRpcWebsocketHandler, self).initialize(*args, **kwargs) diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 106a702..623578b 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -20,24 +20,20 @@ class RpcWebsocketHandler(ArachnadoRPC, websocket.WebSocketHandler): def on_message(self, message): try: - msg = json.loads(message) - event, data = msg['event'], msg['data'] + data = json.loads(message) except (TypeError, ValueError): logger.warn('Invalid message skipped: {!r}'.format(message[:500])) return - if event == 'rpc:request': - self.handle_request(json.dumps(data)) - else: - logger.warn('Unsupported event type: {!r}'.format(event)) + self.handle_request(json.dumps(data)) def send_data(self, data): - self.write_event('rpc:response', data) + self.write_event(data) @gen.coroutine - def write_event(self, event, data): + def write_event(self, data): if isinstance(data, six.string_types): data = json.loads(data) - message = json_encode({'event': event, 'data': data}) + message = json_encode(data) try: self.write_message(message) except websocket.WebSocketClosedError: diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index 23c20e8..42b7bfc 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -36,7 +36,7 @@ JSON-RPC responses:: } Working with jobs ------------------ +-------------------------- JSON-RPC API allows to @@ -59,7 +59,7 @@ New API ======= Working with jobs ------------------ +-------------------------- Open a websocket connection to ``/ws-jobs-data`` in order to use jobs JSON-RPC API for scraping jobs. @@ -72,7 +72,7 @@ subscribe_to_jobs * exclude - an array of regexes; URLs matched by these regexes are excluded from the result; * update_delay - (opional) int, a minimum number of ms between websocket messages; - * id - optional, ObjectID value of a last previously seen job. + * last_job_id - optional, ObjectID value of a last previously seen job. When passed, only new job data is returned. If this parameter set then Arachnado will aggregate job statistics. @@ -179,11 +179,11 @@ subscribe_to_pages {"data": { "status": 200, "items": [], - "_id": "57863974a8cb9c15e8f3d53a", - "url": "http://example.com/index.php", - "headers": {}, - "_type": "page", - "body": ""}, + "_id": "57863974a8cb9c15e8f3d53a", + "url": "http://example.com/index.php", + "headers": {}, + "_type": "page", + "body": ""}, "event": "pages.tailed"} diff --git a/tests/test_data.py b/tests/test_data.py index ce8f6b5..823ca06 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -25,156 +25,81 @@ def get_app(self): @tornado.testing.gen_test def test_set_message_size(self): - test_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_set_0", - 'jsonrpc': '2.0', - 'method': 'set_max_message_size', - 'params': { - "max_size":100 - }, - }, - } + test_command = self.get_command("test_set_0",'set_max_message_size', {"max_size":100}) ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(test_command)) response = yield ws_client.read_message() json_response = json.loads(response) - res = json_response.get("data", {}).get("result", False) + res = json_response.get("result", False) self.assertTrue(res) @tornado.testing.gen_test def test_jobs_no_filter(self): - jobs_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_jobs_0", - 'jsonrpc': '2.0', - 'method': 'subscribe_to_jobs', - 'params': { - }, - }, - } - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri - ws_client = yield tornado.websocket.websocket_connect(ws_url) - ws_client.write_message(json.dumps(jobs_command)) - response = yield ws_client.read_message() - json_response = json.loads(response) - subs_id = json_response.get("data", {}).get("result").get("id", -1) - self.assertNotEqual(subs_id, -1) - self.execute_cancel(ws_client, subs_id, True) + jobs_command = self.get_command("test_jobs_0",'subscribe_to_jobs', {}) + self.execute_jobs_command(jobs_command, wait_result=True) @tornado.testing.gen_test def test_jobs_filter_include(self): - jobs_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_jobs_1", - 'jsonrpc': '2.0', - 'method': 'subscribe_to_jobs', - 'params': { - "include":["127.0.0.1"], - }, - }, - } + jobs_command = self.get_command("test_jobs_1",'subscribe_to_jobs', {"include":["127.0.0.1"],}) + self.execute_jobs_command(jobs_command, wait_result=True) + + def execute_jobs_command(self, jobs_command, wait_result=True): + raise(TimeoutError()) ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(jobs_command)) response = yield ws_client.read_message() json_response = json.loads(response) - subs_id = json_response.get("data", {}).get("result").get("id", -1) + subs_id = json_response.get("result").get("id", -1) self.assertNotEqual(subs_id, -1) cnt = 0 - while cnt < 1: - response = yield ws_client.read_message() - json_response = json.loads(response) - if json_response is None: - self.assertTrue(False) - break - else: - self.assertTrue('stats' in json_response["data"]) - self.assertTrue(isinstance(json_response["data"]["stats"], dict)) - cnt += 1 + if wait_result: + while cnt < 1: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.fail("incorrect response") + break + else: + self.assertTrue('stats' in json_response) + self.assertTrue(isinstance(json_response["stats"], dict)) + cnt += 1 self.execute_cancel(ws_client, subs_id, True) - @tornado.testing.gen_test - def test_pages_no_filter(self): - pages_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_pages_0", - 'jsonrpc': '2.0', - 'method': 'subscribe_to_pages', - 'params': { - }, - }, - } - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri - ws_client = yield tornado.websocket.websocket_connect(ws_url) - ws_client.write_message(json.dumps(pages_command)) - response = yield ws_client.read_message() - json_response = json.loads(response) - subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) - self.assertNotEqual(subs_id, -1) - cnt = 0 - while cnt < 1: - response = yield ws_client.read_message() - json_response = json.loads(response) - if json_response is None: - self.assertTrue(False) - break - else: - self.assertTrue('url' in json_response["data"]) - cnt += 1 - self.execute_cancel(ws_client, subs_id, True) + def test_jobs_filter_include_not_exists(self): + @tornado.gen.coroutine + def f(): + jobs_command = self.get_command("test_jobs_2",'subscribe_to_jobs', {"include":["notexists.com"],}) + self.execute_jobs_command(jobs_command, wait_result=True) + self.assertRaises(TimeoutError, self.io_loop.run_sync, f, timeout=3) @tornado.testing.gen_test def test_pages_filter_url_groups(self): - url_value = 'http://example.com' - pages_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_pages_1", - 'jsonrpc': '2.0', - 'method': 'subscribe_to_pages', - 'params': {'url_groups': {1: {url_value: None}} - } - }, - } - ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri - ws_client = yield tornado.websocket.websocket_connect(ws_url) - ws_client.write_message(json.dumps(pages_command)) - response = yield ws_client.read_message() - json_response = json.loads(response) - subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) - self.assertNotEqual(subs_id, -1) - cnt = 0 - while cnt < 1: - response = yield ws_client.read_message() - json_response = json.loads(response) - if json_response is None: - self.assertTrue(False) - break - else: - self.assertTrue('url' in json_response["data"]) - self.assertTrue(url_value in json_response["data"]["url"]) - cnt += 1 - self.execute_cancel(ws_client, subs_id, True) + url_value = 'http://example2.com' + pages_command = self.get_command("test_pages_0",'subscribe_to_pages', {'url_groups': {1: {url_value: None}}}) + self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) + + @tornado.testing.gen_test + def test_pages_no_filter(self): + pages_command = self.get_command("test_pages_1",'subscribe_to_pages', {}) + self.execute_pages_command(pages_command, wait_result=True) @tornado.testing.gen_test def test_pages_filter_urls(self): url_value = 'http://example.com' - pages_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_pages_2", + pages_command = self.get_command("test_pages_2",'subscribe_to_pages', {'urls': {url_value: None}}) + self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) + + def get_command(self, id, method, params): + return { + 'id': id, 'jsonrpc': '2.0', - 'method': 'subscribe_to_pages', - 'params': {'urls': {url_value: None} - } - }, - } + 'method': method, + 'params': params + } + + def execute_pages_command(self, pages_command, wait_result=False, required_url=None): ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(pages_command)) @@ -182,18 +107,19 @@ def test_pages_filter_urls(self): json_response = json.loads(response) subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) self.assertNotEqual(subs_id, -1) - cnt = 0 - while cnt < 1: - response = yield ws_client.read_message() - json_response = json.loads(response) - if json_response is None: - self.assertTrue(False) - break - else: - self.assertTrue('url' in json_response["data"]) - self.assertTrue(url_value in json_response["data"]["url"]) - cnt += 1 - self.execute_cancel(ws_client, subs_id, True) + if wait_result: + while True: + response = yield ws_client.read_message() + print(response) + json_response = json.loads(response) + if json_response is None: + self.assertTrue(False) + break + else: + self.assertTrue('url' in json_response["data"]) + if required_url: + self.assertTrue(required_url in json_response["data"]["url"]) + self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test def test_wrong_cancel(self): @@ -203,14 +129,11 @@ def test_wrong_cancel(self): def execute_cancel(self, ws_client, subscription_id, expected): jobs_command = { - 'event': 'rpc:request', - 'data': { - 'id': "test_cancel", - 'jsonrpc': '2.0', - 'method': 'cancel_subscription', - 'params': { - "subscription_id": subscription_id - }, + 'id': "test_cancel", + 'jsonrpc': '2.0', + 'method': 'cancel_subscription', + 'params': { + "subscription_id": subscription_id }, } ws_client.write_message(json.dumps(jobs_command)) From 46aa5b7c444baee5d294060e1ec8d2a3d48feed1 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 26 Jul 2016 22:37:52 +0300 Subject: [PATCH 35/44] Tests partial fix --- arachnado/rpc/data.py | 6 ++- arachnado/rpc/pages.py | 7 +++- tests/test_data.py | 84 +++++++++++++++++++----------------------- 3 files changed, 49 insertions(+), 48 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index babf44d..f18caa8 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -138,7 +138,7 @@ def send_updates(self): for job_id in set(self.stored_jobs_stats.keys()): item = self.stored_jobs_stats.pop(job_id, None) if item: - self._send_event(item["event"], item["data"]) + self._send_event(item) def initialize(self, *args, **kwargs): super(JobsDataRpcWebsocketHandler, self).initialize(*args, **kwargs) @@ -264,6 +264,9 @@ def job_query_callback(self, event, data, callback_meta=None): else: logger.warning("Jobs callback with incomplete data") + def on_pages_tailed(self, event, data, callback_meta=None): + self.write_event(data) + def create_jobs_query(self, url): if url: return {"urls":{'$regex': url }} @@ -273,6 +276,7 @@ def create_jobs_query(self, url): def add_storage(self): new_id = str(len(self.storages)) pages = Pages(self, *self.i_args, **self.i_kwargs) + pages.callback = self.on_pages_tailed self.storages[new_id] = DataSubscription(pages) return new_id, self.storages[new_id] diff --git a/arachnado/rpc/pages.py b/arachnado/rpc/pages.py index 682d9ea..07286c6 100644 --- a/arachnado/rpc/pages.py +++ b/arachnado/rpc/pages.py @@ -4,6 +4,7 @@ class Pages(object): """ Pages (scraped items) object exposed via JSON RPC """ handler_id = None + callback = None def __init__(self, handler, item_storage, **kwargs): self.handler = handler @@ -23,5 +24,9 @@ def unsubscribe(self): self.storage.unsubscribe('tailed') def _publish(self, data): + if self.callback: + _callback = self.callback + else: + _callback = self.handler.write_event if self.storage.tailing: - self.handler.write_event('pages.tailed', data) + _callback('pages.tailed', data) diff --git a/tests/test_data.py b/tests/test_data.py index 823ca06..8a65200 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -37,15 +37,15 @@ def test_set_message_size(self): @tornado.testing.gen_test def test_jobs_no_filter(self): jobs_command = self.get_command("test_jobs_0",'subscribe_to_jobs', {}) - self.execute_jobs_command(jobs_command, wait_result=True) + yield self.execute_jobs_command(jobs_command, wait_result=True) @tornado.testing.gen_test def test_jobs_filter_include(self): jobs_command = self.get_command("test_jobs_1",'subscribe_to_jobs', {"include":["127.0.0.1"],}) - self.execute_jobs_command(jobs_command, wait_result=True) + yield self.execute_jobs_command(jobs_command, wait_result=True) + @tornado.gen.coroutine def execute_jobs_command(self, jobs_command, wait_result=True): - raise(TimeoutError()) ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(jobs_command)) @@ -53,43 +53,39 @@ def execute_jobs_command(self, jobs_command, wait_result=True): json_response = json.loads(response) subs_id = json_response.get("result").get("id", -1) self.assertNotEqual(subs_id, -1) - cnt = 0 if wait_result: - while cnt < 1: - response = yield ws_client.read_message() - json_response = json.loads(response) - if json_response is None: - self.fail("incorrect response") - break - else: - self.assertTrue('stats' in json_response) - self.assertTrue(isinstance(json_response["stats"], dict)) - cnt += 1 - self.execute_cancel(ws_client, subs_id, True) + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.fail("incorrect response") + else: + self.assertTrue('stats' in json_response) + self.assertTrue(isinstance(json_response["stats"], dict)) + yield self.execute_cancel(ws_client, subs_id, True) def test_jobs_filter_include_not_exists(self): @tornado.gen.coroutine def f(): jobs_command = self.get_command("test_jobs_2",'subscribe_to_jobs', {"include":["notexists.com"],}) - self.execute_jobs_command(jobs_command, wait_result=True) + yield self.execute_jobs_command(jobs_command, wait_result=True) self.assertRaises(TimeoutError, self.io_loop.run_sync, f, timeout=3) @tornado.testing.gen_test def test_pages_filter_url_groups(self): - url_value = 'http://example2.com' + url_value = 'http://example.com' pages_command = self.get_command("test_pages_0",'subscribe_to_pages', {'url_groups': {1: {url_value: None}}}) - self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) + yield self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) @tornado.testing.gen_test def test_pages_no_filter(self): pages_command = self.get_command("test_pages_1",'subscribe_to_pages', {}) - self.execute_pages_command(pages_command, wait_result=True) + yield self.execute_pages_command(pages_command, wait_result=True) @tornado.testing.gen_test def test_pages_filter_urls(self): url_value = 'http://example.com' pages_command = self.get_command("test_pages_2",'subscribe_to_pages', {'urls': {url_value: None}}) - self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) + yield self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) def get_command(self, id, method, params): return { @@ -99,44 +95,40 @@ def get_command(self, id, method, params): 'params': params } + @tornado.gen.coroutine def execute_pages_command(self, pages_command, wait_result=False, required_url=None): ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(pages_command)) response = yield ws_client.read_message() json_response = json.loads(response) - subs_id = json_response.get("data", {}).get("result").get("single_subscription_id", -1) + subs_id = json_response.get("result").get("single_subscription_id", -1) self.assertNotEqual(subs_id, -1) if wait_result: - while True: - response = yield ws_client.read_message() - print(response) - json_response = json.loads(response) - if json_response is None: - self.assertTrue(False) - break - else: - self.assertTrue('url' in json_response["data"]) - if required_url: - self.assertTrue(required_url in json_response["data"]["url"]) - self.execute_cancel(ws_client, subs_id, True) + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.fail("incorrect response") + else: + self.assertTrue('url' in json_response) + if required_url: + self.assertTrue(required_url in json_response["url"]) + yield self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test def test_wrong_cancel(self): ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) - self.execute_cancel(ws_client, -1, False) + yield self.execute_cancel(ws_client, -1, False) + @tornado.gen.coroutine def execute_cancel(self, ws_client, subscription_id, expected): - jobs_command = { - 'id': "test_cancel", - 'jsonrpc': '2.0', - 'method': 'cancel_subscription', - 'params': { - "subscription_id": subscription_id - }, - } - ws_client.write_message(json.dumps(jobs_command)) - response = yield ws_client.read_message() - json_response = json.loads(response) - self.assertEqual(json_response.get("data", {}).get("result"), expected) + cmd_id = "test_cancel" + cancel_command = self.get_command(cmd_id,'cancel_subscription', {"subscription_id": subscription_id}) + ws_client.write_message(json.dumps(cancel_command)) + while True: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response.get("id", None) == cmd_id: + self.assertEqual(json_response.get("result"), expected) + break From 4b4978d44f8268d5d1890736afcfa61fdf5a8e70 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 27 Jul 2016 18:34:29 +0300 Subject: [PATCH 36/44] minor fix --- docs/json-rpc-api.rst | 111 ++++++++++++++++++++++-------------------- tests/test_data.py | 5 ++ 2 files changed, 63 insertions(+), 53 deletions(-) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index 42b7bfc..3173b4c 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -76,30 +76,34 @@ subscribe_to_jobs When passed, only new job data is returned. If this parameter set then Arachnado will aggregate job statistics. - Response contains subscription ID in ``['data']['result']['id']`` field:: + Response contains subscription ID in ``['result']['id']`` field:: - {'data': {'id': '', - 'jsonrpc': '2.0', - 'result': {'datatype': 'job_subscription_id', 'id': '0'}}, - 'event': 'rpc:response'} + { + 'id': '', + 'jsonrpc': '2.0', + 'result': {'datatype': 'job_subscription_id', 'id': '0'} + } Use this ID to cancel the subscription. After the subscription Arachnado will start to send information about new jobs. Messages look like this:: - {'data': {'_id': '574718bba7a4edb9b026f248', - 'finished_at': '2016-05-26 16:03:17', - 'id': '97ca610fa8c347dbafeca9fcd02213dd', - 'options': {'args': {}, - 'crawl_id': '97ca610fa8c347dbafeca9fcd02213dd', - 'domain': 'scrapy.org', - 'settings': {}}, - 'spider': 'generic', - 'started_at': '2016-05-26 16:03:16', - 'stats': {...}, - 'status': 'finished'}, - 'event': 'jobs.tailed'} + { + '_id': '574718bba7a4edb9b026f248', + 'finished_at': '2016-05-26 16:03:17', + 'id': '97ca610fa8c347dbafeca9fcd02213dd', + 'options': { + 'args': {}, + 'crawl_id': '97ca610fa8c347dbafeca9fcd02213dd', + 'domain': 'scrapy.org', + 'settings': {} + }, + 'spider': 'generic', + 'started_at': '2016-05-26 16:03:16', + 'stats': {...}, + 'status': 'finished' + } cancel_subscription Stop receiving updates about jobs. Parameters: @@ -117,12 +121,12 @@ set_max_message_size Response returns result(true/false) at result field:: - {"event": "rpc:response", - "data": { - "id": "test_set_0", - "result": true, - "jsonrpc": "2.0" - }} + + { + "id": '', + "result": true, + "jsonrpc": "2.0" + } Working with pages (crawled items) @@ -133,6 +137,7 @@ jobs JSON-RPC API for scraping jobs. subscribe_to_pages Get crawled pages(items) for specific urls. + Url values are used as regex without any modifications at Arachnado side. Allows to get all pages or only crawled since last update. To get only new pages set last seen page id (from "id" field of page record) for an url. To get all pages set page id to None. @@ -144,31 +149,32 @@ subscribe_to_pages Command example:: - {'event': 'rpc:request', - 'data': { - 'id': "sample_0", - 'jsonrpc': '2.0', - 'method': 'subscribe_to_pages', - 'params': {'urls': {'http://example.com': None}, - 'url_groups': {'gr1': {'http://example1.com': None}, - 'gr2': {'http://example2.com': "57863974a8cb9c15e8f3d53a"}} - } - }, + { + 'id': '', + 'jsonrpc': '2.0', + 'method': 'subscribe_to_pages', + 'params': { + 'urls': {'http://example.com': None}, + 'url_groups': { + 'gr1': {'http://example1.com': None}, + 'gr2': {'http://example2.com': "57863974a8cb9c15e8f3d53a"}} + } + } } - Response example:: + Response example for above command:: - {"event": "rpc:response", - "data": { + { "result": { - "datatype": "pages_subscription_id", - "single_subscription_id": "112", - "id": { - "gr1": "113", - "gr2": "114", - }}, - "id": "sample_0", - "jsonrpc": "2.0"} + "datatype": "pages_subscription_id", + "single_subscription_id": "112", # subscription id for http://example.com subscription + "id": { + "gr1": "113", # subscription id for http://example1.com subscription + "gr2": "114", # subscription id for http://example2.com subscription + } + }, + "id": '', # command request id + "jsonrpc": "2.0" } Use these IDs to cancel subscriptions. @@ -176,15 +182,15 @@ subscribe_to_pages After the subscription Arachnado will start to send information about crawled pages. Messages look like this:: - {"data": { + { "status": 200, "items": [], "_id": "57863974a8cb9c15e8f3d53a", "url": "http://example.com/index.php", "headers": {}, "_type": "page", - "body": ""}, - "event": "pages.tailed"} + "body": "" + } cancel_subscription @@ -202,12 +208,11 @@ set_max_message_size Response returns result(true/false) at result field:: - {"event": "rpc:response", - "data": { - "id": "test_set_0", - "result": true, - "jsonrpc": "2.0" - }} + { + "id": '',, + "result": true, + "jsonrpc": "2.0" + } .. _JSON-RPC: http://www.jsonrpc.org/specification diff --git a/tests/test_data.py b/tests/test_data.py index 8a65200..43f0974 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -103,6 +103,11 @@ def execute_pages_command(self, pages_command, wait_result=False, required_url=N response = yield ws_client.read_message() json_response = json.loads(response) subs_id = json_response.get("result").get("single_subscription_id", -1) + if not subs_id: + group_sub_ids = json_response.get("result").get("id", {}) + for group_id in group_sub_ids.keys(): + if group_sub_ids[group_id] != -1: + subs_id = group_sub_ids[group_id] self.assertNotEqual(subs_id, -1) if wait_result: response = yield ws_client.read_message() From d1d5d4b1b58c648f6a3d4593d7aff5c10e11c9b1 Mon Sep 17 00:00:00 2001 From: zuev Date: Wed, 27 Jul 2016 18:42:17 +0300 Subject: [PATCH 37/44] --amend --- arachnado/rpc/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index f18caa8..fe4c5ed 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -124,7 +124,7 @@ def write_event(self, data, aggregate=False): item_id = event_data.get("_id", None) if item_id: if item_id in self.stored_jobs_stats: - self.stored_jobs_stats[item_id]["data"]["stats"].update(event_data["stats"]) + self.stored_jobs_stats[item_id]["stats"].update(event_data["stats"]) else: item = event_data self.stored_jobs_stats[item_id] = item From f0feb4968c75eb5849b1f5e1c3bfba3aac6f2903 Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 28 Jul 2016 18:26:31 +0300 Subject: [PATCH 38/44] bug fix + cleanup --- arachnado/rpc/data.py | 19 ++++++++----------- arachnado/rpc/jobs.py | 4 ++-- arachnado/rpc/pages.py | 2 +- arachnado/rpc/ws.py | 5 +++-- docs/json-rpc-api.rst | 20 +++++++------------- tests/test_data.py | 2 +- 6 files changed, 22 insertions(+), 30 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index fe4c5ed..cd09f6b 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -1,5 +1,6 @@ import logging import json +import sys from collections import deque from tornado import gen import tornado.ioloop @@ -22,8 +23,6 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): """ basic class for Data API handlers""" stored_data = None delay_mode = False - # static variable, same for all instances - # event_types = [] heartbeat_data = None i_args = None i_kwargs = None @@ -33,7 +32,7 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): def _send_event(self, data): message = json_encode(data) # if message size is higher then ws connection can be dropped without proper message - if len(message) < self.max_msg_size or not self.max_msg_size: + if sys.getsizeof(message) < self.max_msg_size or not self.max_msg_size: return super(DataRpcWebsocketHandler, self).write_event(data) else: logger.info("Message size exceeded. Message wasn't sent.") @@ -97,13 +96,11 @@ def subscribe_to_jobs(self, include=None, exclude=None, update_delay=0, last_job self.init_heartbeat(update_delay) stor_id, storage = self.add_storage() jobs_storage = Jobs(self, *self.i_args, **self.i_kwargs) - jobs = yield jobs_storage.storage.fetch(query={}) jobs_storage.callback_meta = stor_id - # TODO: set jobs callback here jobs_storage.callback = self.on_jobs_tailed storage.add_jobs_subscription(jobs_storage, include=include, exclude=exclude, last_id=last_job_id) return {"datatype": "job_subscription_id", - "id": stor_id} + "id": stor_id} def add_storage(self): new_id = str(len(self.storages)) @@ -187,8 +184,8 @@ def on_spider_closed(self, spider): if allowed: self.write_event(job) - def on_jobs_tailed(self, event, data, callback_meta=None): - if event == 'jobs.tailed' and "id" in data and callback_meta: + def on_jobs_tailed(self, data, callback_meta=None): + if "id" in data and callback_meta: self.storages[callback_meta].job_ids.add(data["id"]) self.mongo_id_mapping[data["id"]] = data.get("_id", None) self.job_url_mapping[data["id"]] = data.get("urls", None) @@ -256,15 +253,15 @@ def initialize(self, *args, **kwargs): self.dispatcher["subscribe_to_pages"] = self.subscribe_to_pages @gen.coroutine - def job_query_callback(self, event, data, callback_meta=None): - if event == 'jobs.tailed' and "_id" in data and callback_meta: + def job_query_callback(self, data, callback_meta=None): + if "_id" in data and callback_meta: storage = self.storages[callback_meta["subscription_id"]] job_id = data["_id"] storage.update_pages_subscription(job_id, callback_meta["last_id"]) else: logger.warning("Jobs callback with incomplete data") - def on_pages_tailed(self, event, data, callback_meta=None): + def on_pages_tailed(self, data, callback_meta=None): self.write_event(data) def create_jobs_query(self, url): diff --git a/arachnado/rpc/jobs.py b/arachnado/rpc/jobs.py index 5b30381..e59ee30 100644 --- a/arachnado/rpc/jobs.py +++ b/arachnado/rpc/jobs.py @@ -31,6 +31,6 @@ def _publish(self, data): _callback = self.handler.write_event if self.storage.tailing: if self.callback_meta: - _callback('jobs.tailed', data, callback_meta=self.callback_meta) + _callback(data, callback_meta=self.callback_meta) else: - _callback('jobs.tailed', data) + _callback(data) diff --git a/arachnado/rpc/pages.py b/arachnado/rpc/pages.py index 07286c6..014031d 100644 --- a/arachnado/rpc/pages.py +++ b/arachnado/rpc/pages.py @@ -29,4 +29,4 @@ def _publish(self, data): else: _callback = self.handler.write_event if self.storage.tailing: - _callback('pages.tailed', data) + _callback(data) diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 623578b..e3bad46 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -32,8 +32,9 @@ def send_data(self, data): @gen.coroutine def write_event(self, data): if isinstance(data, six.string_types): - data = json.loads(data) - message = json_encode(data) + message = data + else: + message = json_encode(data) try: self.write_message(message) except websocket.WebSocketClosedError: diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index 3173b4c..ef46b68 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -4,12 +4,6 @@ JSON RPC API Arachnado provides JSON-RPC_ API for working with jobs and crawled items (pages). The API works over WebSocket transport. -JSON-RPC request objects are wrapped: -``{"event": "rpc:request", "data": }``. -Responses are also wrapped: -``{"event": "rpc:response", "data": }``. - - JSON-RPC requests have the following format:: { @@ -36,7 +30,7 @@ JSON-RPC responses:: } Working with jobs --------------------------- +----------------- JSON-RPC API allows to @@ -59,7 +53,7 @@ New API ======= Working with jobs --------------------------- +----------------- Open a websocket connection to ``/ws-jobs-data`` in order to use jobs JSON-RPC API for scraping jobs. @@ -71,10 +65,10 @@ subscribe_to_jobs * include - an array of regexes which should match URLs to include; * exclude - an array of regexes; URLs matched by these regexes are excluded from the result; - * update_delay - (opional) int, a minimum number of ms between websocket messages; + * update_delay - (opional) int, a minimum number of ms between websocket messages. If this parameter set then Arachnado will aggregate job statistics; * last_job_id - optional, ObjectID value of a last previously seen job. When passed, only new job data is returned. - If this parameter set then Arachnado will aggregate job statistics. + Response contains subscription ID in ``['result']['id']`` field:: @@ -117,7 +111,7 @@ set_max_message_size Default value is 2**20. To disable this chack set max size to zero. Parameters: - * max_size - an array of regexes which should match URLs to include; + * max_size - maximum message size in bytes. Response returns result(true/false) at result field:: @@ -130,7 +124,7 @@ set_max_message_size Working with pages (crawled items) --------------------------- +---------------------------------- Open a websocket connection to ``/ws-pages-data`` in order to use jobs JSON-RPC API for scraping jobs. @@ -204,7 +198,7 @@ set_max_message_size Default value is 2**20. To disable this chack set max size to zero. Parameters: - * max_size - an array of regexes which should match URLs to include; + * max_size - maximum message size in bytes. Response returns result(true/false) at result field:: diff --git a/tests/test_data.py b/tests/test_data.py index 43f0974..506af55 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -25,7 +25,7 @@ def get_app(self): @tornado.testing.gen_test def test_set_message_size(self): - test_command = self.get_command("test_set_0",'set_max_message_size', {"max_size":100}) + test_command = self.get_command("test_set_0",'set_max_message_size', {"max_size":10000}) ws_url = "ws://localhost:" + str(self.get_http_port()) + self.jobs_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(test_command)) From 1072f28c1b71da6c255fc8abf956fe6cdf3cd611 Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 28 Jul 2016 23:22:50 +0300 Subject: [PATCH 39/44] Message size check update --- arachnado/rpc/data.py | 7 +------ arachnado/rpc/ws.py | 8 ++++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index cd09f6b..9294c50 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -30,12 +30,7 @@ class DataRpcWebsocketHandler(RpcWebsocketHandler): max_msg_size = 2**20 def _send_event(self, data): - message = json_encode(data) - # if message size is higher then ws connection can be dropped without proper message - if sys.getsizeof(message) < self.max_msg_size or not self.max_msg_size: - return super(DataRpcWebsocketHandler, self).write_event(data) - else: - logger.info("Message size exceeded. Message wasn't sent.") + return super(DataRpcWebsocketHandler, self).write_event(data, max_message_size=self.max_msg_size) def init_heartbeat(self, update_delay): if update_delay > 0 and not self.heartbeat_data: diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index e3bad46..420db0d 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -1,3 +1,4 @@ +import sys import json import logging import six @@ -30,13 +31,16 @@ def send_data(self, data): self.write_event(data) @gen.coroutine - def write_event(self, data): + def write_event(self, data, max_message_size=0): if isinstance(data, six.string_types): message = data else: message = json_encode(data) try: - self.write_message(message) + if sys.getsizeof(message) < max_message_size or not max_message_size: + self.write_message(message) + else: + logger.info("Message size exceeded. Message wasn't sent.") except websocket.WebSocketClosedError: pass From 9a5181efabd62ca3cf9a685f9e38446bf17751f3 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Mon, 8 Aug 2016 16:45:26 +0500 Subject: [PATCH 40/44] TST run unit tests in tox --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 588ef70..f143631 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,6 @@ deps = pytest-cov commands = pip install -r requirements.txt - py.test --doctest-modules --cov=arachnado {posargs:arachnado} + py.test --doctest-modules --cov=arachnado {posargs:arachnado tests} From 87304b8fff919507cdf2b7e72eb3952d44935c43 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 9 Aug 2016 15:07:52 +0300 Subject: [PATCH 41/44] Data API bug fix --- arachnado/rpc/data.py | 13 ++++++++----- arachnado/rpc/ws.py | 12 ++++++------ docs/json-rpc-api.rst | 27 ++++++++++++++++++-------- tests/items.jl | 3 ++- tests/test_data.py | 45 +++++++++++++++++++++++++++++++++++-------- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/arachnado/rpc/data.py b/arachnado/rpc/data.py index 9294c50..bd67164 100644 --- a/arachnado/rpc/data.py +++ b/arachnado/rpc/data.py @@ -226,11 +226,14 @@ def create_subscribtion_to_urls(self, urls): jobs_q = self.create_jobs_query(url) jobs_ds = yield jobs.storage.fetch(jobs_q) job_ids =[x["_id"] for x in jobs_ds] - storage.job_ids.update(job_ids) - pages_query = storage.create_pages_query(job_ids, last_id) - storage.filters.append(pages_query) - storage.jobs.append(jobs) - jobs_to_subscribe.append([jobs_q, jobs]) + if job_ids: + storage.job_ids.update(job_ids) + pages_query = storage.create_pages_query(job_ids, last_id) + storage.filters.append(pages_query) + storage.jobs.append(jobs) + jobs_to_subscribe.append([jobs_q, jobs]) + else: + logger.info("No jobs found for url {}".format(url)) storage.subscribe_to_pages() for jobs_q, jobs in jobs_to_subscribe: jobs.subscribe(query=jobs_q) diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 420db0d..9a39ccc 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -20,12 +20,12 @@ class RpcWebsocketHandler(ArachnadoRPC, websocket.WebSocketHandler): """ def on_message(self, message): - try: - data = json.loads(message) - except (TypeError, ValueError): - logger.warn('Invalid message skipped: {!r}'.format(message[:500])) - return - self.handle_request(json.dumps(data)) + # try: + # data = json.loads(message) + # except (TypeError, ValueError): + # logger.warn('Invalid message skipped: {!r}'.format(message[:500])) + # return + self.handle_request(message) def send_data(self, data): self.write_event(data) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index ef46b68..abb01d8 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -29,13 +29,14 @@ JSON-RPC responses:: "result": ... } -Working with jobs ------------------ +Working with jobs and pages +--------------------------- JSON-RPC API allows to * get information about scraping jobs; * start new crawls; +* subscripbe to crawled pages; * subscribe to job updates. jobs.subscribe @@ -43,10 +44,20 @@ jobs.subscribe Parameters: - * last_id - optional, ObjectID value of a last previously seen job. - When passed, only new job data is returned. - * query - optional, MongoDB query - * fields - optional, ... + * last_id - optional, ObjectID value of a last previously seen job; + When passed, only new job data is returned; + * query - optional, MongoDB query; + * fields - optional, set of fields to return. + +pages.subscribe + Get crawled pages and subscribe for new pages. + + Parameters: + + * last_id - optional, ObjectID value of a last previously seen page. + When passed, only new job data is returned; + * query - optional, MongoDB query; + * fields - optional, set of fields to return. New API @@ -139,7 +150,7 @@ subscribe_to_pages Parameters: * urls - a dictionary of :. Arachnado will create one subscription id for all urls; - * url_groups - a dictionary of : . Arachnado will create one subscription id for each url group. + * url_groups - a dictionary of : {:}. Arachnado will create one subscription id for each url group. Command example:: @@ -196,7 +207,7 @@ set_max_message_size Set maximum message size in bytes for websockets channel. Messages larger than specified limit are dropped. Default value is 2**20. - To disable this chack set max size to zero. + To disable this check set max size to zero. Parameters: * max_size - maximum message size in bytes. diff --git a/tests/items.jl b/tests/items.jl index 54029f6..c39786c 100644 --- a/tests/items.jl +++ b/tests/items.jl @@ -1 +1,2 @@ -{"_job_id": "5749d89da8cb9c1f286e3a90","status" : 200, "body" : "", "_type" : "page", "url" : "http://example.com/index.php", "items" : [ ], "headers" : { "Cache-Control" : [ "private, no-cache=\"set-cookie\"" ], "X-Powered-By" : [ "PHP/5.5.9-1ubuntu4.14" ], "Date" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Content-Type" : [ "text/html; charset=UTF-8" ], "Expires" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Vary" : [ "Accept-Encoding" ], "Server" : [ "Apache/2.4.7 (Ubuntu)" ] }, "meta" : { "download_timeout" : 180, "depth" : 2}} \ No newline at end of file +{"_job_id": "5749d89da8cb9c1f286e3a90","status" : 200, "body" : "", "_type" : "page", "url" : "http://mysite.com/index.php", "items" : [ ], "headers" : { "Cache-Control" : [ "private, no-cache=\"set-cookie\"" ], "X-Powered-By" : [ "PHP/5.5.9-1ubuntu4.14" ], "Date" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Content-Type" : [ "text/html; charset=UTF-8" ], "Expires" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Vary" : [ "Accept-Encoding" ], "Server" : [ "Apache/2.4.7 (Ubuntu)" ] }, "meta" : { "download_timeout" : 180, "depth" : 2}} +{"_job_id": "5749d89da8cb9c1f286e3fff","status" : 200, "body" : "", "_type" : "page", "url" : "http://mysite.com", "items" : [ ], "headers" : { "Cache-Control" : [ "private, no-cache=\"set-cookie\"" ], "X-Powered-By" : [ "PHP/5.5.9-1ubuntu4.14" ], "Date" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Content-Type" : [ "text/html; charset=UTF-8" ], "Expires" : [ "Sat, 28 May 2016 17:43:05 GMT" ], "Vary" : [ "Accept-Encoding" ], "Server" : [ "Apache/2.4.7 (Ubuntu)" ] }, "meta" : { "download_timeout" : 180, "depth" : 2}} \ No newline at end of file diff --git a/tests/test_data.py b/tests/test_data.py index 506af55..d60408f 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -76,6 +76,28 @@ def test_pages_filter_url_groups(self): pages_command = self.get_command("test_pages_0",'subscribe_to_pages', {'url_groups': {1: {url_value: None}}}) yield self.execute_pages_command(pages_command, wait_result=True, required_url=url_value) + def test_pages_no_result(self): + @tornado.gen.coroutine + def f(): + url_value = 'http://mysite.com' + pages_command = self.get_command("test_pages_3",'subscribe_to_pages', {'url_groups': {1: {url_value: None}}}) + yield self.execute_pages_command(pages_command, + wait_result=True, + required_url=url_value, + max_count=0) + self.assertRaises(TimeoutError, self.io_loop.run_sync, f, timeout=3) + + def test_pages_exact_count(self): + @tornado.gen.coroutine + def f(): + url_value = 'http://example.com' + pages_command = self.get_command("test_pages_4",'subscribe_to_pages', {'url_groups': {1: {url_value: None}}}) + yield self.execute_pages_command(pages_command, + wait_result=True, + required_url=url_value, + max_count=1) + self.assertRaises(TimeoutError, self.io_loop.run_sync, f, timeout=3) + @tornado.testing.gen_test def test_pages_no_filter(self): pages_command = self.get_command("test_pages_1",'subscribe_to_pages', {}) @@ -96,7 +118,7 @@ def get_command(self, id, method, params): } @tornado.gen.coroutine - def execute_pages_command(self, pages_command, wait_result=False, required_url=None): + def execute_pages_command(self, pages_command, wait_result=False, required_url=None, max_count=None): ws_url = "ws://localhost:" + str(self.get_http_port()) + self.pages_uri ws_client = yield tornado.websocket.websocket_connect(ws_url) ws_client.write_message(json.dumps(pages_command)) @@ -110,14 +132,21 @@ def execute_pages_command(self, pages_command, wait_result=False, required_url=N subs_id = group_sub_ids[group_id] self.assertNotEqual(subs_id, -1) if wait_result: - response = yield ws_client.read_message() - json_response = json.loads(response) - if json_response is None: - self.fail("incorrect response") + if max_count is None: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.fail("incorrect response") else: - self.assertTrue('url' in json_response) - if required_url: - self.assertTrue(required_url in json_response["url"]) + cnt = 0 + while True: + response = yield ws_client.read_message() + json_response = json.loads(response) + if json_response is None: + self.fail("incorrect response") + cnt += 1 + if cnt > max_count: + self.fail("max count of pages exceeded") yield self.execute_cancel(ws_client, subs_id, True) @tornado.testing.gen_test From d4cf8f0df91e92bd200554e1c4fb510a98753ee8 Mon Sep 17 00:00:00 2001 From: zuev Date: Tue, 9 Aug 2016 21:02:56 +0300 Subject: [PATCH 42/44] code cleanup --- arachnado/rpc/ws.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/arachnado/rpc/ws.py b/arachnado/rpc/ws.py index 9a39ccc..834edbf 100644 --- a/arachnado/rpc/ws.py +++ b/arachnado/rpc/ws.py @@ -20,11 +20,6 @@ class RpcWebsocketHandler(ArachnadoRPC, websocket.WebSocketHandler): """ def on_message(self, message): - # try: - # data = json.loads(message) - # except (TypeError, ValueError): - # logger.warn('Invalid message skipped: {!r}'.format(message[:500])) - # return self.handle_request(message) def send_data(self, data): From c6f3eb73ae01d3c05d007b7c507a5e659d8cb4ff Mon Sep 17 00:00:00 2001 From: zuev Date: Thu, 11 Aug 2016 16:55:27 +0300 Subject: [PATCH 43/44] Documentation update --- docs/json-rpc-api.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index abb01d8..b282eb5 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -144,6 +144,10 @@ subscribe_to_pages Get crawled pages(items) for specific urls. Url values are used as regex without any modifications at Arachnado side. Allows to get all pages or only crawled since last update. + Search function uses job start urls, not page urls. + For example, if job was started for www.mysite.com and then goes to www.example.com (by redirect, etc.), + all its pages will be returned by www.mysite.com search query. + To search pages by its own urls use pages.subscribe method described above. To get only new pages set last seen page id (from "id" field of page record) for an url. To get all pages set page id to None. From e30181b005340bc5b22e445200c7f79a250187f8 Mon Sep 17 00:00:00 2001 From: zuev Date: Fri, 12 Aug 2016 13:36:12 +0300 Subject: [PATCH 44/44] Docs minor error fix --- docs/json-rpc-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/json-rpc-api.rst b/docs/json-rpc-api.rst index b282eb5..e642ff8 100644 --- a/docs/json-rpc-api.rst +++ b/docs/json-rpc-api.rst @@ -120,7 +120,7 @@ set_max_message_size Set maximum message size in bytes for websockets channel. Messages larger than specified limit are dropped. Default value is 2**20. - To disable this chack set max size to zero. + To disable this check set max size to zero. Parameters: * max_size - maximum message size in bytes.