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

Revision 112, 49.9 KB checked in by lostclus, 2 weeks ago (diff)

Fixed template charset determining code.

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