source: LinkExchange/trunk/linkexchange/clients/sape.py @ 257

Revision 257, 51.0 KB checked in by lostclus, 3 months ago (diff)

Fixed duplicate keys output when parsing XML.

Line 
1# LinkExchange - Universal link exchange service client
2# Copyright (C) 2009 Konstantin Korikov
3#
4# This library is free software; you can redistribute it and/or
5# modify it under the terms of the GNU Lesser General Public
6# License as published by the Free Software Foundation; either
7# version 2.1 of the License, or (at your option) any later version.
8#
9# This library is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12# Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public
15# License along with this library; if not, write to the Free Software
16# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17#
18# NOTE: In the context of the Python environment, I interpret "dynamic
19# linking" as importing -- thus the LGPL applies to the contents of
20# the modules, but make no requirements on code importing these
21# modules.
22
23import os
24import random
25import urllib
26import urllib2
27import urlparse
28import datetime
29import re
30import xml.sax
31import xml.sax.saxutils
32import xml.dom
33import xml.dom.pulldom
34import StringIO
35import HTMLParser
36import tempfile
37import logging
38
39try:
40    set
41except NameError:
42    from sets import Set as set
43
44import mimetypes
45if not mimetypes.inited:
46  mimetypes.init()
47
48try:
49    import phpserialize
50except ImportError:
51    phpserialize = None
52
53from linkexchange.tests import SimpleFileTestServer
54from linkexchange.clients.base import BaseClient
55from linkexchange.clients.base import ClientError, \
56        ClientNetworkError, ClientDataError, ClientDataAccessError
57from linkexchange.clients.base import PageResponse
58from linkexchange.utils import is_plugin_specifier, load_plugin
59from linkexchange.utils import urlopen_with_timeout, urlopen_errors
60from linkexchange.utils import default_user_agent, normalize_uri
61
62log = logging.getLogger('linkexchange.clients.sape')
63
64class SapeLikeClient(BaseClient):
65    """
66    Base class for Sape-like clients.
67    """
68    db_lifetime = datetime.timedelta(seconds = 3600)
69    db_reloadtime = datetime.timedelta(seconds = 600)
70    socket_timeout = 6
71    force_show_code = True
72    no_query_string = False
73    server_charset = 'utf-8'
74    user_agent = default_user_agent
75
76    def __init__(self, user, **kw):
77        """
78        SapeLikeClient constructor.
79       
80        The user is hash code string that assigned to user on link exchange
81        service.
82
83        The db_lifetime keyword argument specifies database lifetime. Database
84        older than this time interval will be updated be calling refresh_db().
85        Value can be datetime.timedelta object or number of seconds as integer
86        or float value. None value disables database refreshing, but refreshing
87        will occur if database is not exist even if db_lifetime is None.
88
89        The db_reloadtime specifies the time interval during which the database
90        refreshing is not happens if the previous attempt to refreshing was
91        failed. This prevents remote server overloading when problem on client
92        side. Value can be datetime.timedelta object or number of seconds as
93        integer or float value. None value means that db_lifetime value should
94        be used.
95
96        The socket_timeout is socket timeout for remote connections in seconds.
97
98        @param user: user hash code string on link exchange service
99        @keyword db_lifetime: DB lifetime as datetime.timedelta object or
100                              number of seconds as numeric value or None
101        @keyword db_reloadtime: DB reload time as datetime.timedelta object or
102                              number of seconds as numeric value or None
103        @keyword socket_timeout: socket timeout in seconds
104        @keyword force_show_code: if True force to show check code
105        @keyword no_query_string: if True the query string will be ignored when
106                                  selecting links for requested URL, and check
107                                  code will not be shown if URL has a query
108                                  string
109        @keyword server_charset: server data charset
110        @keyword user_agent: user agent string
111        """
112        self.user = user
113        for param in ('db_lifetime', 'db_reloadtime', 'socket_timeout',
114                'force_show_code', 'no_query_string', 'server_charset',
115                'user_agent'):
116            if param in kw:
117                setattr(self, param, kw[param])
118        for param in ('db_lifetime', 'db_reloadtime'):
119            value = getattr(self, param)
120            if type(value) in (int, long, float):
121                setattr(self, param, datetime.timedelta(seconds = value))
122
123    def normalize_host(self, request):
124        host = request.host.lower()
125        if host.startswith('www.'):
126            host = host[len('www.'):]
127        return host
128
129    def load_data(self, db_driver, server_list, format, request):
130        def save_error(host, data, error):
131            new_data = {}
132            new_data.update(data)
133            new_data['__error_time__'] =  datetime.datetime.now()
134            new_data['__error_value__'] = error
135            return db_driver.save(host, new_data, blocking = False)
136
137        host = self.normalize_host(request)
138        data = None
139        force_refresh = False
140        try:
141            data = db_driver.load(host)
142        except KeyError:
143            log.debug("No existing database found, creating new one")
144        if data is not None and self.db_lifetime is not None:
145            reloadtime = self.db_reloadtime
146            if reloadtime is None:
147                reloadtime = self.db_lifetime
148            try:
149                refresh_after = data['__error_time__'] + reloadtime
150            except KeyError:
151                refresh_after = (db_driver.get_mtime(host) +
152                        self.db_lifetime)
153            if refresh_after <= datetime.datetime.now():
154                log.debug("The database too old, refreshing")
155                force_refresh = True
156        if data is None:
157            try:
158                self.refresh_data(db_driver, server_list, format, request)
159            except ClientError, e:
160                if not save_error(host, {}, e):
161                    raise e
162            data = db_driver.load(host)
163        elif force_refresh:
164            try:
165                self.refresh_data(db_driver, server_list, format, request)
166            except ClientDataAccessError:
167                pass
168            except ClientError, e:
169                save_error(host, data, e)
170            data = db_driver.load(host)
171        return data
172
173    def get_links(self, data, request):
174        uri = str(request.uri)
175        if self.no_query_string:
176            try:
177                uri = uri[:uri.index('?')]
178            except ValueError:
179                pass
180        links = self.get_links_for_page(data, request, uri)
181        if not links:
182            links = self.get_links_new_page(data, request)
183        return links
184
185    def get_links_for_page(self, data, request, uri):
186        return data.get(uri, [])
187
188    def get_links_new_page(self, data, request):
189        if self.is_check_code_visible(data, request):
190            check_code = self.get_check_code(data, request)
191            if check_code:
192                return [check_code]
193        return []
194
195    def get_check_code(self, data, request):
196        return ''
197
198    def get_delimiter(self, data, request):
199        return ''
200
201    def is_bot(self, data, request):
202        return False
203
204    def is_check_code_visible(self, data, request):
205        if not self.force_show_code and not self.is_bot(data, request):
206            return False
207        if self.no_query_string and '?' in request.uri:
208            return False
209        return True
210
211    def transform_code(self, data, request, code):
212        return code
213
214    def load_links_data(self, request):
215        return None
216
217    def get_raw_links(self, request):
218        log.debug("Getting raw links for: %s", request.url())
219        data = self.load_links_data(request)
220        links = self.get_links(data, request)
221        return [self.transform_code(data, request, code) for code in links]
222
223    def get_html_links(self, request):
224        log.debug("Getting HTML links for: %s", request.url())
225        data = self.load_links_data(request)
226        links = self.get_links(data, request)
227        delim = self.get_delimiter(data, request)
228        html = delim.join(links)
229        return self.transform_code(data, request, html)
230
231    def deep_server_charset_decode(self, value):
232        if type(value) == str:
233            value = unicode(value, self.server_charset)
234        elif type(value) == dict:
235            for k in value.keys():
236                value[k] = self.deep_server_charset_decode(value[k])
237        elif type(value) == list:
238            for i in range(len(value)):
239                value[i] = self.deep_server_charset_decode(value[i])
240        return value
241
242    def parse_link(self, link):
243        return self.deep_server_charset_decode(link)
244
245    def parse_param(self, name, value):
246        return self.deep_server_charset_decode(value)
247
248    def parse_data(self, source, url, format):
249        if format == 'php':
250            raw_data = source.read()
251            if raw_data.startswith('FATAL ERROR:'):
252                log.error("Server error: %s: %s", raw_data, url)
253                raise ClientError(raw_data)
254            try:
255                data = phpserialize.loads(raw_data)
256            except ValueError, e:
257                log.error("Could not deserialize response from server: %s: %s", str(e), url)
258                raise ClientDataError('Could not deserialize response '
259                        'from server: %s' % str(e))
260            for key, value in data.items():
261                if key.startswith('/'):
262                    if type(value) == dict:
263                        value = value.values()
264                    yield (normalize_uri(key), map(self.parse_link, value))
265                else:
266                    yield (key, self.parse_param(key, value))
267
268    def fetch_data(self, url, format):
269        req = urllib2.Request(url)
270        req.add_header('User-Agent', self.user_agent)
271        req.add_header('Accept-Charset', self.server_charset)
272        try:
273            log.debug("Fetching: %s", url)
274            f = urlopen_with_timeout(req, self.socket_timeout)
275            return self.parse_data(f, url, format)
276        except urlopen_errors, e:
277            log.error("Network error: %s: %s", str(e), url)
278            raise ClientNetworkError('Network error: %s' % str(e))
279
280    def refresh_data(self, db_driver, server_list, format, request):
281        host = self.normalize_host(request)
282        server_list = server_list[:]
283        random.shuffle(server_list)
284        server_list = iter(server_list)
285        data = None
286        error = None
287        while data is None:
288            try:
289                server = server_list.next()
290                url = server % dict(user=self.user, host=host)
291                data = self.fetch_data(url, format)
292            except StopIteration:
293                raise error
294            except ClientError, e:
295                error = e
296                continue
297        if not db_driver.save(host, data, blocking=False):
298            log.warning("Other process/tread is currently writes to the database")
299            raise ClientDataAccessError(
300                    "Other process/tread is currently writes to the database")
301
302class SapeClient(SapeLikeClient):
303    """
304    Sape.ru client.
305
306    """
307
308    server_list = [
309            'http://dispenser-01.sape.ru/code.php?user=%(user)s&host=%(host)s&charset=utf-8&as_xml=1',
310            'http://dispenser-02.sape.ru/code.php?user=%(user)s&host=%(host)s&charset=utf-8&as_xml=1']
311    server_format = 'xml'
312
313    def __init__(self, user, db_driver, **kw):
314        """
315        SapeClient constructor.
316       
317        The user is hash code string that assigned to user on link exchange
318        service.
319
320        The db_driver argument is multihash database driver instance or plugin
321        specifier. In second case plugin specifier is used to create new
322        instance. The database driver instance is used to store links database.
323
324        @param user: user hash code string on link exchange service
325        @param db_driver: multihash database driver instance or plugin specifier
326        @keyword server_list: list of servers URLs
327        @keyword server_format: server output data format 'xml' or 'php'
328        """
329        super(SapeClient, self).__init__(user, **kw)
330        if is_plugin_specifier(db_driver):
331            db_driver = load_plugin('linkexchange.multihash_drivers', db_driver)
332        self.db_driver = db_driver
333        for param in ('server_list', 'server_format'):
334            if param in kw:
335                setattr(self, param, kw[param])
336        if 'use_xml' in kw:
337            log.warning("The use_xml parameter is depricated!")
338            self.server_format = kw['use_xml'] and 'xml' or 'php'
339        if 'xml_server_list' in kw:
340            log.warning("The xml_server_list parameter is depricated!")
341            self.server_list = kw['xml_server_list']
342        log.debug("New %s instance:\n%s",
343                self.__class__.__name__,
344                '\n'.join(["    %s: %s" % (x, repr(getattr(self, x)))
345                    for x in (
346                        'user',
347                        'db_driver',
348                        'db_lifetime',
349                        'db_reloadtime',
350                        'socket_timeout',
351                        'force_show_code',
352                        'no_query_string',
353                        'server_list',
354                        'server_format',
355                        'server_charset',
356                        'user_agent')]))
357
358    def load_links_data(self, request):
359        return self.load_data(self.db_driver, self.server_list,
360                self.server_format, request)
361
362    def refresh_db(self, request):
363        self.refresh_data(self.db_driver, self.server_list,
364                self.server_format, request)
365
366    def get_check_code(self, data, request):
367        return data.get('__sape_new_url__', '')
368
369    def get_delimiter(self, data, request):
370        return data.get('__sape_delimiter__', '')
371
372    def is_bot(self, data, request):
373        return request.cookies.get('sape_cookie') == self.user
374
375    def transform_code(self, data, request, code):
376        if self.is_bot(data, request):
377            code = '<sape_noindex>%s</sape_noindex>' % code
378        return code
379
380    def parse_data(self, source, url, format):
381        def node_text(node):
382            return u''.join([sn.nodeValue for sn in node.childNodes
383                if sn.nodeType == xml.dom.Node.TEXT_NODE])
384
385        def parse_xml(events):
386            path = []
387            keys_send = set()
388            try:
389                for (event, node) in events:
390                    if event == xml.dom.pulldom.START_ELEMENT:
391                        path.append(node.tagName)
392                        if path == ['sape', 'page']:
393                            events.expandNode(node)
394                            path.pop()
395                            uri = node.getAttribute('uri')
396                            if uri == '*':
397                                if '__sape_new_url__' not in keys_send:
398                                    yield ('__sape_new_url__', node_text(node))
399                                    keys_send.add('__sape_new_url__')
400                            else:
401                                uri = normalize_uri(uri)
402                                link_nodes = node.getElementsByTagName('link')
403                                if uri.startswith('/'):
404                                    link_f = self.parse_link
405                                else:
406                                    link_f = lambda x: x
407                                if uri not in keys_send:
408                                    yield (uri, [link_f(node_text(x))
409                                        for x in link_nodes])
410                                    keys_send.add(uri)
411                        elif path == ['sape', 'sape_ips']:
412                            events.expandNode(node)
413                            path.pop()
414                            ip_nodes = node.getElementsByTagName('ip')
415                            if '__sape_ips__' not in keys_send:
416                                yield ('__sape_ips__',
417                                        [node_text(x) for x in ip_nodes])
418                                keys_send.add('__sape_ips__')
419                        elif path == ['sape']:
420                            delimiter = node.getAttribute('delimiter')
421                            if delimiter:
422                                if '__sape_delimiter__' not in keys_send:
423                                    yield ('__sape_delimiter__', delimiter)
424                                    keys_send.add('__sape_delimiter__')
425                    elif event == xml.dom.pulldom.END_ELEMENT:
426                        path.pop()
427            except xml.sax.SAXParseException, e:
428                log.error("Could not parse XML data: %s: %s", str(e), url)
429                raise ClientDataError('Could not parse XML data: %s' % str(e))
430            except urlopen_errors, e:
431                log.error("Network error: %s: %s", str(e), url)
432                raise ClientNetworkError('Network error: %s' % str(e))
433
434        if format == 'xml':
435            return parse_xml(xml.dom.pulldom.parse(source))
436        return super(SapeClient, self).parse_data(source, url, format)
437
438class ContextLinksGenerator(HTMLParser.HTMLParser):
439    def __init__(self, out, links,
440            is_fragment, show_code, new_url_code,
441            force_body_sape_index = False,
442            exclude_tags = None,
443            include_tags = None):
444        HTMLParser.HTMLParser.__init__(self)
445        self.out = out
446        self.char_buf = []
447        self.links = links
448        self.is_fragment = is_fragment
449        self.show_code = show_code
450        self.new_url_code = new_url_code
451        self.force_body_sape_index = force_body_sape_index
452        self.exclude_tags = (exclude_tags or set()) | set(['a',
453            'textarea', 'select', 'script', 'style',
454            'label', 'noscript' , 'noindex', 'button'])
455        self.exclude_ctx = []
456        self.include_tags = include_tags or set()
457        self.include_ctx = []
458
459    def handle_starttag(self, tag, attrs):
460        self.handle_realdata()
461        if self.is_fragment:
462            ignore = tag in ('html', 'body')
463        else:
464            ignore = not self.show_code and tag == 'sape_index'
465        if not ignore:
466            self.out.write(u'<' + tag)
467            for k, v in attrs:
468                self.out.write(' %s="%s"' % (k, xml.sax.saxutils.escape(v)))
469            self.out.write(u'>')
470        if tag == 'body' and self.show_code:
471            if ((not self.is_fragment and self.force_body_sape_index) or
472                    self.is_fragment):
473                self.out.write('<sape_index>')
474        if tag in self.exclude_tags:
475            self.exclude_ctx.append(tag)
476        elif tag in self.include_tags:
477            self.include_ctx.append(tag)
478
479    def handle_endtag(self, tag):
480        self.handle_realdata()
481        if tag == 'body' and self.show_code:
482            if ((not self.is_fragment and self.force_body_sape_index) or
483                    self.is_fragment):
484                self.out.write('</sape_index>')
485                if self.new_url_code:
486                    self.out.write(self.new_url_code)
487        if self.is_fragment:
488            ignore = tag in ('html', 'body')
489        else:
490            ignore = not self.show_code and tag == 'sape_index'
491        if not ignore:
492            self.out.write('</%s>' % tag)
493            if tag == 'sape_index' and self.show_code and self.new_url_code:
494                self.out.write(self.new_url_code)
495        if tag in self.exclude_tags:
496            self.exclude_ctx.pop()
497        elif tag in self.include_tags:
498            self.include_ctx.pop()
499
500    def handle_startendtag(self, tag, attrs):
501        self.handle_realdata()
502        self.out.write(u'<' + tag)
503        for k, v in attrs:
504            self.out.write(' %s="%s"' % (k, xml.sax.saxutils.escape(v)))
505        self.out.write(u'/>')
506
507    def handle_data(self, data):
508        self.char_buf.append(data)
509
510    def handle_charref(self, name):
511        self.char_buf.append('&#%s;' % name)
512
513    def handle_entityref(self, name):
514        self.char_buf.append('&%s;' % name)
515
516    def handle_realdata(self):
517        content = ''.join(self.char_buf)
518        self.char_buf[:] = []
519        if not self.exclude_ctx:
520            if set(self.include_ctx) == self.include_tags:
521                for sentence_re, link in self.links:
522                    content = sentence_re.sub(link, content, count = 1)
523        self.out.write(content)
524
525    def handle_comment(self, data):
526        self.char_buf.append('<!--%s-->' % data)
527
528    def handle_decl(self, data):
529        self.char_buf.append('<!%s>' % data)
530
531    def handle_pi(self, data):
532        self.char_buf.append('<?%s>' % data)
533
534class SapeContextClient(SapeClient):
535    """
536    Sape.ru client for context links.
537    """
538
539    force_show_code = False
540    server_list = [
541            'http://dispenser-01.sape.ru/code_context.php?user=%(user)s&host=%(host)s&charset=utf-8&as_xml=1',
542            'http://dispenser-02.sape.ru/code_context.php?user=%(user)s&host=%(host)s&charset=utf-8&as_xml=1']
543    server_format = 'xml'
544
545    def __init__(self, user, db_driver, **kw):
546        super(SapeContextClient, self).__init__(user, db_driver, **kw)
547        self.tags_re = re.compile(r'<[^>]*>')
548        self.start_doc_re = re.compile(r'^\s*<(\?xml|!DOCTYPE|html|HTML)\b', re.S)
549
550    def get_raw_links(self, request):
551        return []
552
553    def get_html_links(self, request):
554        return u''
555
556    def content_filter(self, request, content):
557        data = self.load_links_data(request)
558        show_code = self.is_bot(data, request) or self.force_show_code
559        links = data.get(str(request.uri), [])
560        force_body_sape_index = False
561        include_tags = set()
562        if self.start_doc_re.match(content):
563            is_fragment = False
564            include_tags = set(['body'])
565            if '<sape_index>' not in content:
566                force_body_sape_index = True
567            else:
568                include_tags |= set(['sape_index'])
569        else:
570            is_fragment = True
571            content = '<html><body>%s</body></html>' % content
572        out = StringIO.StringIO()
573        generator = ContextLinksGenerator(out, links,
574                is_fragment = is_fragment,
575                show_code = show_code,
576                new_url_code = data.get('__sape_new_url__', ''),
577                force_body_sape_index = force_body_sape_index,
578                include_tags = include_tags)
579        generator.feed(content)
580        generator.close()
581        return out.getvalue()
582
583    def parse_link(self, link):
584        link = super(SapeContextClient, self).parse_link(link)
585        sentence = re.escape(xml.sax.saxutils.unescape(
586            self.tags_re.sub('', link)))
587        sentence.replace(' ', r'(\s|(&nbsp;))+')
588        return (re.compile(sentence, re.S + re.UNICODE), link)
589
590class ArticleTemplateLinksCutter(HTMLParser.HTMLParser):
591    def __init__(self, out, allowed_domains):
592        HTMLParser.HTMLParser.__init__(self)
593        self.out = out
594        self.char_buf = []
595        self.allowed_domains = allowed_domains
596        self.exclude_tags = set(['script', 'noindex'])
597        self.exclude_ctx = []
598        self.anchor_needs_noindex = []
599
600    def handle_starttag(self, tag, attrs):
601        self.handle_realdata()
602        if not self.exclude_ctx:
603            if tag == 'a':
604                self.anchor_needs_noindex.append(False)
605                href = ''
606                for k, v in attrs:
607                    if k == 'href': href = v
608                if href.startswith('http'):
609                    url = urlparse.urlsplit(href)
610                    if not url[1] or url[1] not in self.allowed_domains:
611                        self.out.write(u'<noindex>')
612                        self.anchor_needs_noindex[-1] = True
613                        attrs = ([(k, v) for k, v in attrs if k != 'rel'] +
614                                [('rel', 'nofollow')])
615        self.out.write(u'<' + tag)
616        for k, v in attrs:
617            if v is not None:
618                self.out.write(' %s="%s"' % (k, xml.sax.saxutils.escape(v)))
619            else:
620                self.out.write(' %s' % k)
621        self.out.write(u'>')
622        if tag in self.exclude_tags:
623            self.exclude_ctx.append(tag)
624
625    def handle_endtag(self, tag):
626        self.handle_realdata()
627        self.out.write('</%s>' % tag)
628        if not self.exclude_ctx:
629            if tag == 'a':
630                if self.anchor_needs_noindex.pop():
631                    self.out.write(u'</noindex>')
632        if tag in self.exclude_tags:
633            self.exclude_ctx.pop()
634
635    def handle_startendtag(self, tag, attrs):
636        self.handle_realdata()
637        self.out.write(u'<' + tag)
638        for k, v in attrs:
639            self.out.write(' %s="%s"' % (k, xml.sax.saxutils.escape(v)))
640        self.out.write(u'/>')
641
642    def handle_data(self, data):
643        self.char_buf.append(data)
644
645    def handle_charref(self, name):
646        self.char_buf.append('&#%s;' % name)
647
648    def handle_entityref(self, name):
649        self.char_buf.append('&%s;' % name)
650
651    def handle_realdata(self):
652        content = ''.join(self.char_buf)
653        self.char_buf[:] = []
654        self.out.write(content)
655
656    def handle_comment(self, data):
657        self.char_buf.append('<!--%s-->' % data)
658
659    def handle_decl(self, data):
660        self.char_buf.append('<!%s>' % data)
661
662    def handle_pi(self, data):
663        self.char_buf.append('<?%s>' % data)
664
665class SapeArticlesClient(SapeLikeClient):
666    """
667    Sape.ru articles client.
668    """
669
670    force_show_code = False
671    index_server_list = [
672            ('http://dispenser.articles.sape.ru/?'
673                'user=%(user)s&host=%(host)s&rtype=index'),
674            ]
675    article_server_list = [
676            ('http://dispenser.articles.sape.ru/?'
677                'user=%(user)s&host=%(host)s&rtype=article&'
678                'artid=%(article_id)s'),
679            ]
680    image_server_list = [
681            ('http://dispenser.articles.sape.ru/'),
682            ]
683    output_charset = 'utf-8'
684
685    _template_charset_re = re.compile(r'<meta\s+http-equiv="Content-Type"\s+'
686            r'content="text/html;\s*charset=(?P<charset>[\w-]+)"\s*/?>',
687            re.I + re.S)
688
689    def __init__(self, user, index_db_driver, article_db_driver,
690            image_db_driver, template_db_driver, **kw):
691        """
692        SapeClient constructor.
693       
694        The user is hash code string that assigned to user on link exchange
695        service.
696
697        The index_db_driver, article_db_driver, image_db_driver and
698        template_db_driver arguments is multihash database drivers instances or
699        plugin specifiers. In second case plugin specifier is used to create
700        new instance. The database drivers instances is used to store index,
701        articles, images and templates databases.
702
703        @param user: user hash code string on link exchange service
704        @param index_db_driver: multihash databases driver instance or plugin
705                                specifier used to store index
706        @param article_db_driver: multihash databases driver instance or plugin
707                                  specifier used to store articles
708        @param image_db_driver: multihash databases driver instance or plugin
709                                specifier used to store images
710        @param template_db_driver: multihash databases driver instance or
711                                   plugin specifier used to store templates
712        @param index_server_list: list of servers URLs to get index
713        @param article_server_list: list of servers URLs to get articles
714        @param image_server_list: list of servers URLs to get images
715        """
716        super(SapeArticlesClient, self).__init__(user, **kw)
717        if is_plugin_specifier(index_db_driver):
718            index_db_driver = load_plugin('linkexchange.multihash_drivers',
719                    index_db_driver)
720        self.index_db_driver = index_db_driver
721        if is_plugin_specifier(article_db_driver):
722            article_db_driver = load_plugin('linkexchange.multihash_drivers',
723                    article_db_driver)
724        self.article_db_driver = article_db_driver
725        if is_plugin_specifier(image_db_driver):
726            image_db_driver = load_plugin('linkexchange.multihash_drivers',
727                    image_db_driver)
728        self.image_db_driver = image_db_driver
729        if is_plugin_specifier(template_db_driver):
730            template_db_driver = load_plugin('linkexchange.multihash_drivers',
731                    template_db_driver)
732        self.template_db_driver = template_db_driver
733        for param in ('index_server_list', 'article_server_list',
734                'image_server_list', 'output_charset'):
735            if param in kw:
736                setattr(self, param, kw[param])
737        log.debug("New %s instance:\n%s",
738                self.__class__.__name__,
739                '\n'.join(["    %s: %s" % (x, repr(getattr(self, x)))
740                    for x in (
741                        'user',
742                        'db_lifetime',
743                        'db_reloadtime',
744                        'socket_timeout',
745                        'force_show_code',
746                        'no_query_string',
747                        'user_agent',
748                        'index_db_driver',
749                        'article_db_driver',
750                        'image_db_driver',
751                        'template_db_driver',
752                        'index_server_list',
753                        'article_server_list',
754                        'image_server_list',
755                        'server_charset',
756                        'output_charset')]))
757
758    def load_links_data(self, request):
759        return self.load_data(self.index_db_driver, self.index_server_list,
760                'php', request)
761
762    def load_data2(self, db_driver, host):
763        while True:
764            try:
765                return db_driver.load(host)
766            except KeyError:
767                log.debug("No existing database found, creating new one")
768                db_driver.save(host, {}, blocking=False)
769                continue
770
771    def fetch_template(self, host, template_url, template_id, index):
772        template = {'id': template_id,
773                'date_updated': datetime.datetime.now()}
774
775        if template_url.startswith('file://'):
776            pathname = os.path.normcase(urllib.url2pathname(
777                    urlparse.urlsplit(template_url)[2]))
778            log.debug("Template URL refer to local file: %s", pathname)
779            allow =  pathname.startswith(
780                            os.path.normcase(tempfile.gettempdir()))
781            if not allow:
782                log.error("URL is not allowed: %s", template_url)
783                raise ClientNetworkError('URL is not allowed: %s' %
784                        template_url)
785            url = template_url
786        else:
787            url = list(urlparse.urlsplit(template_url))
788            url[0:2] = ['http', host]
789            url = urlparse.urlunsplit(url)
790
791        log.debug("Fetching template %s: %s", template_id, url)
792        try:
793            f = urlopen_with_timeout(url, self.socket_timeout)
794            raw_data = f.read()
795            f.close()
796        except urlopen_errors, e:
797            log.error("Network error: %s: %s", str(e), url)
798            raise ClientNetworkError('Network error: %s' % str(e))
799
800        charset = None
801        content_type = f.info().getheader('Content-Type')
802        if content_type:
803            for x in content_type.split(';'):
804                if x.lstrip().startswith('charset='):
805                    charset = x.strip()[len('charset='):]
806        if not charset:
807            m = self._template_charset_re.search(raw_data)
808            if m:
809                charset = m.group('charset')
810
811        template['body'] = unicode(raw_data, charset or 'ascii')
812
813        for field, tag in index.get('template_required_fields', {}).items():
814            if '{%s}' % field not in template['body']:
815                log.error("Missing template field: %s", field)
816                raise ClientDataError('Missing template field: %s' % field)
817
818        allowed_domains = set(index['ext_links_allowed'] +
819                [host, 'www.' + host])
820        out = StringIO.StringIO()
821        cutter = ArticleTemplateLinksCutter(out, allowed_domains)
822        cutter.feed(template['body'])
823        cutter.close()
824        template['body'] = out.getvalue()
825
826        return template
827
828    def load_template(self, request, template_meta, template_id, index):
829        now = datetime.datetime.now()
830        host = self.normalize_host(request)
831        data = self.load_data2(self.template_db_driver, host)
832        try:
833            template = data[str(template_meta['url'])]
834        except KeyError:
835            template = None
836        else:
837            if (self.db_lifetime is not None and
838                    now - template['date_updated'] > template_meta['lifetime']):
839                template = None
840        if template is None:
841            template = self.fetch_template(host,
842                    template_meta['url'], template_id, index)
843            self.template_db_driver.modify(host,
844                    {str(template_meta['url']): template})
845        return template
846
847    def fetch_article(self, host, article_id):
848        def do_fetch_article(url):
849            log.debug("Fetching article %s: %s", article_id, url)
850            try:
851                f = urlopen_with_timeout(url, self.socket_timeout)
852                raw_data = f.read()
853                f.close()
854            except urlopen_errors, e:
855                log.error("Network error: %s: %s", str(e), url)
856                raise ClientNetworkError('Network error: %s' % str(e))
857            if raw_data.startswith('FATAL ERROR:'):
858                log.error("Server error: %s: %s", raw_data, url)
859                raise ClientError(raw_data)
860            try:
861                return phpserialize.loads(raw_data)
862            except ValueError, e:
863                log.error("Could not deserialize response from server: %s: %s",
864                        str(e), url)
865                raise ClientDataError('Could not deserialize response '
866                        'from server: %s' % str(e))
867
868        server_list = self.article_server_list[:]
869        random.shuffle(server_list)
870        server_list = iter(server_list)
871        article = None
872        while article is None:
873            try:
874                server = server_list.next()
875                url = server % dict(user=self.user, host=host,
876                        article_id=article_id)
877                article = do_fetch_article(url)
878                if 'date_updated' in article:
879                    article['date_updated'] = datetime.datetime.fromtimestamp(
880                            int(article['date_updated']))
881                for k in article.keys():
882                    if type(article[k]) == str:
883                        article[k] = unicode(article[k], self.server_charset)
884            except StopIteration:
885                raise error
886            except ClientError, e:
887                error = e
888                continue
889        return article
890
891    def load_article(self, request, article_meta):
892        now = datetime.datetime.now()
893        host = self.normalize_host(request)
894        data = self.load_data2(self.article_db_driver, host)
895        article_url = request.uri
896        try:
897            article = data[str(article_url)]
898        except KeyError:
899            article = None
900        else:
901            if self.db_lifetime is not None:
902                if ('date_updated' not in article or
903                        article['date_updated'] != article_meta['date_updated']):
904                    article = None
905        if article is None:
906            article = self.fetch_article(host, article_meta['id'])
907            self.article_db_driver.modify(host,
908                    {str(article_url): article})
909        return article
910
911    def fetch_image(self, host, dispenser_path):
912        def do_fetch_image(url):
913            log.debug("Fetching image: %s", url)
914            try:
915                f = urlopen_with_timeout(url, self.socket_timeout)
916                raw_data = f.read()
917                f.close()
918            except urlopen_errors, e:
919                log.error("Network error: %s: %s", str(e), url)
920                raise ClientNetworkError('Network error: %s' % str(e))
921            if raw_data.startswith('FATAL ERROR:'):
922                log.error("Server error: %s: %s", raw_data, url)
923                raise ClientError(raw_data)
924            return {'date_updated': datetime.datetime.now(),
925                    'image': raw_data}
926        server_list = self.image_server_list[:]
927        random.shuffle(server_list)
928        server_list = iter(server_list)
929        image = None
930        while image is None:
931            try:
932                server = server_list.next()
933                url = server % dict(user=self.user, host=host)
934                if url.endswith('/') and dispenser_path.startswith('/'):
935                    url = url[:-1]
936                url += dispenser_path
937                image = do_fetch_image(url)
938                mime, enc = mimetypes.guess_type('file.' + image_meta['ext'])
939                image['mime'] = mime or 'image/jpeg'
940            except StopIteration:
941                raise error
942            except ClientError, e:
943                error = e
944                continue
945        return image
946
947    def load_image(self, request, image_meta):
948        now = datetime.datetime.now()
949        host = self.normalize_host(request)
950        data = self.load_data2(self.image_db_driver, host)
951        image_url = request.uri
952        try:
953            image = data[str(image_url)]
954        except KeyError:
955            image = None
956        else:
957            if self.db_lifetime is not None:
958                if ('date_updated' not in image or
959                        image['date_updated'] < image_meta['date_updated']):
960                    image = None
961        if image is None:
962            image = self.fetch_image(host, image_meta['dispenser_path'])
963            self.image_db_driver.modify(host,
964                    {str(image_url): image})
965        return image
966
967    def refresh_db(self, request):
968        # refresh index
969        self.refresh_data(self.index_db_driver, self.index_server_list,
970                'php', request)
971
972        index = self.load_links_data(request)
973        now = datetime.datetime.now()
974        host = self.normalize_host(request)
975
976        # refresh templates cache
977        data = self.load_data2(self.template_db_driver, host)
978        data_to_update = {}
979        data_to_delete = []
980        article_meta = template_meta = None
981        try:
982            article_meta = index['article_' + str(request.uri)]
983        except KeyError:
984            pass
985        if article_meta:
986            template_id = article_meta['template_id']
987            try:
988                template_meta = index['template_' + template_id]
989            except KeyError:
990                log.warning("Article with non existing template: %s: %s",
991                        str(request.uri), template_id)
992        if template_meta:
993            template_url = template_meta['url']
994            try:
995                date_updated = data[template_url]['date_updated']
996            except KeyError:
997                date_updated = None
998            if (not date_updated or
999                    now - date_updated > template_meta['lifetime']):
1000                try:
1001                    data_to_update[template_url] = self.fetch_template(host,
1002                            template_url, template_id, index)
1003                except ClientError:
1004                    pass
1005        for template_url, template in data.items():
1006            index_key = 'template_' + str(template['id'])
1007            try:
1008                template_meta = index[index_key]
1009            except KeyError:
1010                data_to_delete.append(template_url)
1011                continue
1012            if template_url in data_to_update:
1013                continue
1014            if now - template['date_updated'] > template_meta['lifetime']:
1015                try:
1016                    data_to_update[template_url] = self.fetch_template(host,
1017                            template_meta['url'], template['id'], index)
1018                except ClientError:
1019                    pass
1020        if data_to_delete:
1021            self.template_db_driver.delete(host, data_to_delete)
1022        if data_to_update:
1023            self.template_db_driver.modify(host, data_to_update)
1024
1025        # refresh articles
1026        data = self.load_data2(self.article_db_driver, host)
1027        data_to_update = {}
1028        data_to_delete = []
1029        for article_url, article in data.items():
1030            index_key = 'article_' + str(article_url)
1031            try:
1032                article_meta = index[index_key]
1033            except KeyError:
1034                data_to_delete.append(article_url)
1035                continue
1036            if ('date_updated' not in article or
1037                    article['date_updated'] != article_meta['date_updated']):
1038                try:
1039                    data_to_update[article_url] = self.fetch_article(host,
1040                            article_meta['id'])
1041                except ClientError:
1042                    pass
1043        if data_to_delete:
1044            self.article_db_driver.delete(host, data_to_delete)
1045        if data_to_update:
1046            self.article_db_driver.modify(host, data_to_update)
1047
1048        # refresh images
1049        data = self.load_data2(self.image_db_driver, host)
1050        data_to_update = {}
1051        data_to_delete = []
1052        for image_url, image in data.items():
1053            index_key = 'image_' + str(image_url)
1054            try:
1055                image_meta = index['image_' + str(image_url)]
1056            except KeyError:
1057                data_to_delete.append(image_url)
1058                continue
1059            if ('date_updated' not in image or
1060                    image['date_updated'] < image_meta['date_updated']):
1061                try:
1062                    data_to_update[image_url] = self.fetch_image(host,
1063                            image_meta['dispenser_path'])
1064                except ClientError:
1065                    pass
1066        if data_to_delete:
1067            self.image_db_driver.delete(host, data_to_delete)
1068        if data_to_update:
1069            self.image_db_driver.modify(host, data_to_update)
1070
1071    def get_links_for_page(self, data, request, uri):
1072        links = data.get('announcement_' + uri, [])
1073        links2 = self.get_links_new_page(data, request)
1074        if links2:
1075            if not links:
1076                links = ['']
1077            links[0] = links2[0] + links[0]
1078        return links
1079
1080    def get_check_code(self, data, request):
1081        return data.get('checkCode', '')
1082
1083    def get_delimiter(self, data, request):
1084        return data.get('announcements_delimiter', '')
1085
1086    def is_bot(self, data, request):
1087        return request.cookies.get('sape_cookie') == self.user
1088
1089    def transform_code(self, data, request, code):
1090        if self.is_bot(data, request) and data.get('checkCode', ''):
1091            code = '<sape_noindex>%s</sape_noindex>' % code
1092        return code
1093
1094    def parse_param(self, name, value):
1095        if name in ('template_fields', 'ext_links_allowed'):
1096            if type(value) == dict:
1097                value = value.values()
1098        elif name == 'template_required_fields':
1099            if type(value) == dict and value:
1100                if type(value.keys()[0]) == int:
1101                    value = value.values()
1102            if type(value) == list:
1103                value = dict([(x, None) for x in value])
1104        return super(SapeArticlesClient, self).parse_param(name, value)
1105
1106    def parse_data(self, source, url, format):
1107        if format == 'php':
1108            raw_data = source.read()
1109            if raw_data.startswith('FATAL ERROR:'):
1110                log.error("Server error: %s: %s", raw_data, url)
1111                raise ClientError(raw_data)
1112            try:
1113                data = phpserialize.loads(raw_data)
1114            except ValueError, e:
1115                log.error("Could not deserialize response from server: %s: %s", str(e), url)
1116                raise ClientDataError('Could not deserialize response '
1117                        'from server: %s' % str(e))
1118            for uri, links in data.pop('announcements').items():
1119                if type(links) == dict:
1120                    links = links.values()
1121                yield ('announcement_' + normalize_uri(uri),
1122                        map(self.parse_link, links))
1123            for uri, article_meta in data.pop('articles').items():
1124                article_meta['date_updated'] = datetime.datetime.fromtimestamp(
1125                        int(article_meta['date_updated']))
1126                yield ('article_' + normalize_uri(uri), article_meta)
1127            for uri, image_meta in data.pop('images').items():
1128                image_meta['date_updated'] = datetime.datetime.fromtimestamp(
1129                        int(image_meta['date_updated']))
1130                yield ('image_' + normalize_uri(uri), image_meta)
1131            for tpl_id, template_meta in data.pop('templates').items():
1132                template_meta['lifetime'] = datetime.timedelta(0,
1133                        int(template_meta['lifetime']))
1134                yield ('template_' + str(tpl_id), template_meta)
1135            for key, value in data.items():
1136                yield (key, self.parse_param(key, value))
1137
1138    def handle_request(self, request):
1139        def return_article(index, article_meta):
1140            template_id = article_meta['template_id']
1141            try:
1142                template_meta = index['template_' + template_id]
1143            except KeyError:
1144                log.error("Template not found in template index: %s",
1145                        template_id)
1146                raise ClientDataError('Template not found in template '
1147                        'index: %s' % template_id)
1148            template = self.load_template(request, template_meta,
1149                    template_id, index)
1150            article = self.load_article(request, article_meta)
1151            article_body = template['body']
1152            article_body = article_body.replace('{meta_charset}',
1153                    self.output_charset)
1154            for field in index['template_fields']:
1155                article_body = article_body.replace(
1156                        '{%s}' % field, article.get(field, ''))
1157            if self.is_bot(index, request):
1158                article_body += '<!--sape_noindex-->'
1159            content_type = 'text/html; charset=%s' % self.output_charset
1160            return PageResponse(status=200,
1161                    body=article_body.encode(self.output_charset),
1162                    headers={'Content-Type': content_type})
1163
1164        def return_image(index, image_meta):
1165            image = self.load_image(request, image_meta)
1166            return PageResponse(status=200, body=image['image'],
1167                    headers={'Content-Type': image['mime']})
1168
1169        index = self.load_links_data(request)
1170        try:
1171            article_meta = index['article_' + str(request.uri)]
1172        except KeyError:
1173            pass
1174        else:
1175            return return_article(index, article_meta)
1176        try:
1177            image_meta = index['image_' + str(request.uri)]
1178        except KeyError:
1179            pass
1180        else:
1181            return return_image(index, image_meta)
1182        if self.is_bot(index, request):
1183            return PageResponse(status=200,
1184                    body=index.get('checkCode', '') + '<!--sape_noindex-->',
1185                    headers={'Content-Type': 'text/html'})
1186        return PageResponse(status=404)
1187
1188class SapeLikeTestServer(SimpleFileTestServer):
1189    data = None
1190    extra_data = None
1191    server_format = 'xml'
1192
1193    def __init__(self, filename=None, data=None, extra_data=None,
1194            server_format=None):
1195        if data is not None:
1196            self.data = data
1197        if extra_data is not None:
1198            self.extra_data = extra_data
1199        if self.extra_data:
1200            self.data.update(self.extra_data)
1201        if server_format is not None:
1202            self.server_format = server_format
1203        raw_data = self.format_data(self.data)
1204        super(SapeLikeTestServer, self).__init__(filename=filename,
1205                raw_data=raw_data)
1206
1207    def format_data(self, data):
1208        if self.server_format == 'php':
1209            return phpserialize.dumps(data)
1210        return ''
1211
1212class SapeTestServer(SapeLikeTestServer):
1213    """
1214    Test server to test Sape clients.
1215    """
1216    data = {
1217        '/': [
1218            '<a href="http://example1.com">example text 1</a>',
1219            '<a href="http://example2.com">example text 2</a>',
1220            ],
1221        '/path/1': [
1222            '<a href="http://example1.com">example text 1</a>',
1223            '<a href="http://example2.com">example text 2</a>',
1224            '<a href="http://example3.com">example text 3</a>',
1225            '<a href="http://example4.com">example text 4</a>',
1226            ],
1227        '/path/2': [
1228            'Plain text and <a href="url">link text</a>'],
1229        '__sape_new_url__': '<!--12345-->',
1230        '__sape_delimiter__': '. ',
1231        }
1232
1233    def format_data(self, data):
1234        def xml_make_page(uri, links):
1235            return '<page uri="%s">%s</page>' % (uri.encode('utf-8'),
1236                    ''.join(['<link><![CDATA[%s]]></link>' % s.encode('utf-8')
1237                        for s in links]))
1238
1239        if self.server_format == 'xml':
1240            pages = '\n'.join([xml_make_page(uri, links)
1241                for uri, links in data.items() if uri.startswith('/')])
1242
1243            lines = [
1244                    '<?xml version="1.0" encoding="UTF-8"?>',
1245                    '<sape delimiter="%s">' % data.get('__sape_delimiter__',
1246                        u'').encode('utf-8'),
1247                    pages,
1248                    '<page uri="*"><![CDATA[%s]]></page>' % data.get(
1249                        '__sape_new_url__', u'').encode('utf-8'),
1250                    '</sape>']
1251            return '\n'.join(lines)
1252        return super(SapeTestServer, self).format_data(data)
1253
1254class SapeArticlesIndexTestServer(SapeLikeTestServer):
1255    server_format = 'php'
1256    data = {
1257            'announcements': {
1258                '/': [
1259                    '<a href="/articles/1">ann link 1</a>',
1260                    '<a href="/articles/1">ann link 2</a>'],
1261                },
1262            'articles': {
1263                '/articles/1': {
1264                    'id': '1',
1265                    'date_updated': '0',
1266                    'template_id': '1',
1267                    },
1268                },
1269            'images': {},
1270            'templates': {
1271                '1': {
1272                    'lifetime': '3600',
1273                    },
1274                },
1275            'template_fields': [
1276                'title', 'keywords', 'header', 'body',
1277                ],
1278            'template_required_fields': {
1279                'title': 'title',
1280                'keywords': 'meta',
1281                'header': 'h1',
1282                'body': 'body',
1283                },
1284            'ext_links_allowed': [],
1285            'checkCode': '<!-- announcements place -->',
1286            'announcements_delimiter': '|',
1287            }
1288
1289    def __init__(self, template_urls=[], **kw):
1290        kw.setdefault('data', self.data)
1291        for template_id, template_url in template_urls:
1292            kw['data']['templates'][template_id]['url'] = template_url
1293        super(SapeArticlesIndexTestServer, self).__init__(**kw)
1294
1295class SapeArticlesArticleTestServer(SapeLikeTestServer):
1296    server_format = 'php'
1297    data = {
1298            'date_updated': '0',
1299            'title': 'The article title',
1300            'keywords': 'The keywords',
1301            'header': 'The article header',
1302            'body': '<p>The article <a href="http://example.com">body</a>.</p>',
1303            }
1304
1305class SapeArticlesTemplateTestServer(SimpleFileTestServer):
1306    raw_data = """
1307    <html>
1308      <head>
1309        <title>{title}</title>
1310        <meta name="keywords" content="{keywords}"/>
1311      </head>
1312    <body>
1313      <h1>{header}</h1>
1314      {body}
1315      <div id="footer">
1316        <a href="http://external-link.com">External link</a>
1317      </div>
1318    </body>
1319    </html>
1320    """
1321
1322if __name__ == "__main__":
1323    import doctest
1324    doctest.testmod()
Note: See TracBrowser for help on using the repository browser.