├── .circleci └── config.yml ├── .gitignore ├── AUTHORS ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── circle.yml ├── django_seo_js ├── __init__.py ├── backends │ ├── __init__.py │ ├── base.py │ ├── prerender.py │ └── test.py ├── helpers.py ├── middleware │ ├── __init__.py │ ├── escaped_fragment.py │ ├── hashbang.py │ └── useragent.py ├── settings.py ├── templatetags │ ├── __init__.py │ └── django_seo_js.py └── tests │ ├── __init__.py │ ├── backends │ ├── __init__.py │ ├── test_base.py │ ├── test_prerender_hosted.py │ └── test_prerender_io.py │ ├── test_helpers.py │ ├── test_middlewares.py │ ├── test_pep8.py │ ├── test_templatetags.py │ └── utils.py ├── fabfile.py ├── manage.py ├── requirements.dev.txt ├── requirements.tests.txt ├── requirements.txt ├── runtime.txt ├── settings.py ├── setup.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. 2 | # See: https://circleci.com/docs/2.0/configuration-reference 3 | version: 2.1 4 | 5 | # Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects. 6 | # See: https://circleci.com/docs/2.0/orb-intro/ 7 | orbs: 8 | # The python orb contains a set of prepackaged CircleCI configuration you can use repeatedly in your configuration files 9 | # Orb commands and jobs help you with common scripting around a language/tool 10 | # so you dont have to copy and paste it everywhere. 11 | # See the orb documentation here: https://circleci.com/developer/orbs/orb/circleci/python 12 | python: circleci/python@1.2 13 | 14 | # Define a job to be invoked later in a workflow. 15 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs 16 | jobs: 17 | build-and-test: # This is the name of the job, feel free to change it to better match what you're trying to do! 18 | # These next lines defines a Docker executors: https://circleci.com/docs/2.0/executor-types/ 19 | # You can specify an image from Dockerhub or use one of the convenience images from CircleCI's Developer Hub 20 | # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python 21 | # The executor is the environment in which the steps below will be executed - below will use a python 3.8 container 22 | # Change the version below to your required version of python 23 | docker: 24 | - image: cimg/python:3.8 25 | # Checkout the code as the first step. This is a dedicated CircleCI step. 26 | # The python orb's install-packages step will install the dependencies from a Pipfile via Pipenv by default. 27 | # Here we're making sure we use just use the system-wide pip. By default it uses the project root's requirements.txt. 28 | # Then run your tests! 29 | # CircleCI will report the results back to your VCS provider. 30 | steps: 31 | - checkout 32 | - python/install-packages: 33 | pkg-manager: pip 34 | # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. 35 | pip-dependency-file: requirements.tests.txt # if you have a different name for your requirements file, maybe one that combines your runtime and test requirements. 36 | - run: 37 | name: Run tests 38 | # This assumes pytest is installed via the install-package step above 39 | command: tox 40 | 41 | # Invoke jobs via workflows 42 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows 43 | workflows: 44 | sample: # This is the name of the workflow, feel free to change it to better match your workflow. 45 | # Inside the workflow, you define the jobs you want to run. 46 | jobs: 47 | - build-and-test 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | .mr.developer.cfg 30 | .project 31 | .pydevproject 32 | .dewey_autocomplete.sh 33 | 34 | README.html 35 | 36 | shelf.db 37 | .idea/* 38 | venv 39 | 40 | django_seo_js/.DS_Store 41 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Steven Skoczen - https://github.com/skoczen 2 | alex-mcleod - https://github.com/alex-mcleod 3 | andrewebdev - https://github.com/andrewebdev 4 | denisvlr - https://github.com/denisvlr 5 | mattrobenolt - https://github.com/mattrobenolt 6 | thoop - https://github.com/thoop 7 | rchrd2 - https://github.com/rchrd2 8 | chazcb - https://github.com/chazcb 9 | Pi Delport - https://github.com/pjdelport 10 | Paul Craciunoiu - https://github.com/pcraciunoiu 11 | jdotjdot - https://github.com/jdotjdot 12 | zekzekus - https://github.com/zekzekus 13 | bhoop77 - https://github.com/bhoop77 14 | varrocs - https://github.com/varrocs 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This is a first pass at a contribution doc, and will change, but for starters: 2 | 3 | - Incoming code should follow PEP8 (there's a test to help out on this.) 4 | - If you add new core-level features, write some quick docs in the README. If you're not sure if they're needed, just ask! 5 | - Add your name and attribution to the AUTHORS file. 6 | - Know you have our thanks for helping to make django-seo-js even better! 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 GreenKahuna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md AUTHORS LICENSE 2 | include *.txt 3 | include *.md 4 | include *.py 5 | recursive-include django_seo_js/templates * 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-seo-js 2 | ============= 3 | 4 | [](https://circleci.com/gh/skoczen/django-seo-js/tree/master)   5 | 6 | django-seo-js is a drop-in app that provides full SEO support for angular, backbone, ember, famo.us, and other SPA apps built with django. 7 | 8 | It's simple to set up, configurable to use multiple services, and easy to customize. 9 | 10 | Quick-links: 11 | - [Installation](#installation) 12 | - [Options](#options) 13 | - [General Settings](#General-Settings) 14 | - [Backend settings](#Backend-settings) 15 | - [Prerender.io](#Prerender-io) 16 | - [Custom-hosted prerender](#custom-hosted-prerender) 17 | - [Advanced Usage](#advanced-usage) 18 | - [Updating the render cache](#updating-the-render-cache) 19 | - [How it all works](#how-it-all-works) 20 | - [Contributing](#contributing) 21 | - [Code](#code) 22 | - [Culture](#culture) 23 | - [Authors](#authors) 24 | - [Releases](#releases) 25 | 26 | 27 | # Installation 28 | 29 | 1. Pip install: 30 | 31 | ```bash 32 | pip install django-seo-js 33 | ``` 34 | 35 | 36 | 2. Add to your `settings.py`: 37 | 38 | ```python 39 | # If in doubt, just include both. Details below. 40 | MIDDLEWARE_CLASSES = ( 41 | 'django_seo_js.middleware.EscapedFragmentMiddleware', # If you're using #! 42 | 'django_seo_js.middleware.UserAgentMiddleware', # If you want to detect by user agent 43 | ) + MIDDLEWARE_CLASSES 44 | 45 | INSTALLED_APPS += ('django_seo_js',) 46 | 47 | # If you're using prerender.io (the default backend): 48 | SEO_JS_PRERENDER_TOKEN = "123456789abcdefghijkl" # Really, put this in your env, not your codebase. 49 | ``` 50 | 51 | 3. Add to your `base.html` 52 | 53 | ```twig 54 | {% load django_seo_js %} 55 |
56 | {% seo_js_head %} 57 | ... 58 | 59 | ``` 60 | 61 | 4. **That's it. :)** Your js-heavy pages are now rendered properly to the search engines. Have a lovely day. 62 | 63 | Want more advanced control? Keep reading. 64 | 65 | 66 | # Options 67 | 68 | ## General settings 69 | 70 | For the most part, you shouldn't need to override these - we've aimed for sensible defaults. 71 | 72 | ```python 73 | # Backend to use 74 | SEO_JS_BACKEND = "django_seo_js.backends.PrerenderIO" # Default 75 | 76 | # Whether to run the middlewares and update_cache_for_url. Useful to set False for unit testing. 77 | SEO_JS_ENABLED = True # Defaults to *not* DEBUG. 78 | 79 | # User-agents to render for, if you're using the UserAgentMiddleware 80 | # Defaults to the most popular. If you have custom needs, pull from the full list: 81 | # http://www.useragentstring.com/pages/Crawlerlist/ 82 | SEO_JS_USER_AGENTS = [ 83 | "Googlebot", 84 | "Yahoo", 85 | "bingbot", 86 | "Badiu", 87 | "Ask Jeeves", 88 | ] 89 | 90 | # Urls to skip the rendering backend, and always render in-app. 91 | # Defaults to excluding sitemap.xml. 92 | SEO_JS_IGNORE_URLS = [ 93 | "/sitemap.xml", 94 | ] 95 | SEO_JS_IGNORE_EXTENSIONS = [ 96 | ".xml", 97 | ".txt", 98 | # See helpers.py for full list of extensions ignored by default. 99 | ] 100 | 101 | # Whether or not to pass along the original request's user agent to the prerender service. 102 | # Useful for analytics, understanding where requests are coming from. 103 | SEO_JS_SEND_USER_AGENT = True 104 | ``` 105 | 106 | ## Backend settings 107 | 108 | ### Prerender.io 109 | django-seo-js defaults to using prerender.io because it's both [open-source](https://github.com/prerender/prerender) if you want to run it yourself, *and* really reasonably priced if you don't. 110 | 111 | 112 | To use [prerender.io](http://prerender.io), 113 | 114 | ```python 115 | # Prerender.io token 116 | SEO_JS_PRERENDER_TOKEN = "123456789abcdefghijkl" 117 | ``` 118 | 119 | You don't need to set `SEO_JS_BACKEND`, since it defaults to `"django_seo_js.backends.PrerenderIO"`. 120 | 121 | 122 | ### Custom-hosted prerender 123 | 124 | If you're hosting your own instance of [prerender](https://github.com/prerender/prerender), (there are [docker images](https://github.com/cerisier/docker-prerender/), for those inclined,) configuration is similar 125 | 126 | ```python 127 | SEO_JS_BACKEND = "django_seo_js.backends.PrerenderHosted" 128 | SEO_JS_PRERENDER_URL = "http://my-prerenderapp.com/" # Note trailing slash. 129 | SEO_JS_PRERENDER_RECACHE_URL = "http://my-prerenderapp.com/recache" 130 | ``` 131 | 132 | ### Writing your own backend 133 | 134 | If it's a backend for a public service, please consider submitting your backend as a PR, so everyone can benefit! 135 | 136 | Backends must implement the following methods: 137 | 138 | ```python 139 | 140 | class MyBackend(SEOBackendBase): 141 | 142 | def get_response_for_url(self, url, request=None): 143 | """ 144 | Accepts a fully-qualified url. 145 | Optionally accepts the django request object, so that headers, etc. may be passed along to the prerenderer. 146 | Returns an HttpResponse, passing through all headers and the status code. 147 | """ 148 | raise NotImplementedError 149 | 150 | def update_url(self, url): 151 | """ 152 | Force an update of the cache for a particular URL. 153 | Returns True on success, False on fail. 154 | """ 155 | raise NotImplementedError 156 | ``` 157 | 158 | If you're hitting an http endpoint, there's also the helpful `RequestsBasedBackend`, which has a `build_django_response_from_requests_response` method that transforms a [python-requests](http://docs.python-requests.org/) response to a django HttpResponse, including headers, status codes, etc. 159 | 160 | # Advanced Usage 161 | 162 | ## Updating the render cache 163 | 164 | If you know a page's contents have changed, some backends allow you to manually update the page cache. `django-seo-js` provides helpers to make that easy. 165 | 166 | ```python 167 | from django_seo_js.helpers import update_cache_for_url 168 | 169 | update_cache_for_url("/my-url") 170 | ``` 171 | 172 | So, for instance, you might want something like: 173 | 174 | ```python 175 | def listing_changed(sender, instance, created, **kwargs): 176 | update_cache_for_url("%s%s" % ("http://example.com/", reverse("listing_detail", instance.pk)) 177 | 178 | post_save.connect(listing_changed, sender=Listing) 179 | ``` 180 | 181 | ## Building your own URLs for prerendering 182 | 183 | If you need to customize the fully-qualified URL, you can subclass any backend and override the `build_absolute_uri()` method. 184 | 185 | ```python 186 | class MyBackend(SEOBackendBase): 187 | def build_absolute_uri(self, request): 188 | """Strip out all query params:""" 189 | return '{scheme}://{host}{path}'.format( 190 | scheme=self.scheme, 191 | host=self.get_host(), 192 | path=self.path, 193 | ) 194 | ``` 195 | 196 | 197 | # How it all works 198 | 199 | If you're looking for a big-picture explanation of how SEO for JS-heavy apps is handled, the clearest explanation I've seen is [this StackOverflow answer](http://stackoverflow.com/a/20766253). 200 | 201 | If even that's TL;DR for you, here's a bullet-point summary: 202 | 203 | - If requests come in with an `_escaped_fragment_` querystring or a particular user agent, a pre-rendered HTML response is served, instead of your app. 204 | - That pre-rendered HTML is generated by a service with a headless browser that runs your js then caches the rendered page. 205 | - Said service is generally a third party (there are many: [prerender.io](https://prerender.io/), [Brombone](http://www.brombone.com/), [seo.js](http://getseojs.com/), [seo4ajax](http://www.seo4ajax.com/).) You can also run such a service yourself, using [prerender](https://github.com/prerender/prerender), or re-invent your own wheel for fun. 206 | 207 | 208 | # Contributing 209 | 210 | ## Code 211 | 212 | PRs with additional backends, bug-fixes, documentation and more are definitely welcome! 213 | 214 | Here's some guidelines on new code: 215 | - Incoming code should follow PEP8 (there's a test to help out on this.) 216 | - If you add new core-level features, write some quick docs in the README. If you're not sure if they're needed, just ask! 217 | - Add your name and attribution to the AUTHORS file. 218 | - Know you have everyone's thanks for helping to make django-seo-js even better! 219 | 220 | ## Culture 221 | 222 | Anyone is welcome to contribute to django-seo-js, regardless of skill level or experience. To make django-seo-js the best it can be, we have one big, overriding cultural principle: 223 | 224 | **Be kind.** 225 | 226 | Simple. Easy, right? 227 | 228 | We've all been newbie coders, we've all had bad days, we've all been frustrated with libraries, we've all spoken a language we learned later in life. In discussions with other coders, PRs, and CRs, we just give each the benefit of the doubt, listen well, and assume best intentions. It's worked out fantastically. 229 | 230 | This doesn't mean we don't have honest, spirited discussions about the direction to move django-seo-js forward, or how to implement a feature. We do. We just respect one other while we do it. Not so bad, right? :) 231 | 232 | 233 | # Authors 234 | 235 | django-seo-js was originally written and is maintained by [Steven Skoczen](https://stevenskoczen.com). Since then, it's been improved by lots of people, including (alphabetically): 236 | 237 | - [alex-mcleod](https://github.com/alex-mcleod) brought you the idea of ignoring certain urls via `SEO_JS_IGNORE_URLS`. 238 | - [andrewebdev](https://github.com/andrewebdev) improved the user-agent list to be more comprehensive. 239 | - [chazcb](https://github.com/chazcb) added the `build_absolute_uri` method, for subclassing in complex, generated setups. 240 | - [denisvlr](https://github.com/denisvlr) fixed the `update_url` method. 241 | - [mattrobenolt](https://github.com/mattrobenolt) mad things faster, better, and stronger. 242 | - [rchrd2](https://github.com/rchrd2) fixed a breaking bug with the user agent middleware. 243 | - [thoop](https://github.com/thoop) gave you `SEO_JS_IGNORE_EXTENSIONS`, allowing you to ignore by extension. 244 | - [bhoop77](https://github.com/bhoop77) fixed the defaults to work wiht Googlebot's new setup. 245 | - [varrocs](https://github.com/varrocs) updated the list of user agents to match prerender.io's current list. 246 | - [sarahboyce](https://github.com/sarahboyce) added support for Django 4.1. 247 | 248 | 249 | 250 | Original development was at GreenKahuna (now defunct.) 251 | 252 | # Releases 253 | 254 | ### 0.4.1 - Sep 27, 2022 255 | Patch middleware for Django 4.1. [PR](https://github.com/skoczen/django-seo-js/pull/44). 256 | 257 | ### 0.4.0 - Sep 26, 2022 258 | Adds support for Django 4.1. [PR](https://github.com/skoczen/django-seo-js/pull/42). 259 | 260 | ### 0.3.5 - Nov 26, 2021 261 | Adds more default user agents to bring things into the present day. [issue](https://github.com/skoczen/django-seo-js/pull/41). 262 | 263 | ### 0.3.4 - Jan 8, 2020 264 | Fixes googlebot defaults [issue](https://github.com/skoczen/django-seo-js/issues/39). 265 | 266 | ### 0.3.3 - Jan 8, 2020 267 | 268 | Add `SEO_JS_SEND_USER_AGENT` setting. 269 | 270 | ### 0.3.2 - March 6, 2019 271 | 272 | See [releases](https://github.com/skoczen/django-seo-js/releases) 273 | 274 | ### 0.3.1 - March 3, 2015 275 | 276 | * **Deprecation**: `django_seo_js.middleware.HashBangMiddleware` is now called `django_seo_js.middleware.EscapedFragmentMiddleware`, to fix confusion. `HashBangMiddleware` will be removed in 0.5. Which I would bet is probably late 2015, early 2016. You'll see a log warning from now on. Thanks to [thoop](https://github.com/thoop) for the report. 277 | * Bugfix to user agent middleware not respecting `ENABLED`, thanks to [rchrd2](https://github.com/rchrd2). Also reported by [denisvlr](https://github.com/denisvlr). 278 | * New (backwards-compatible) `build_absolute_uri` method that can be overridden, thanks to [chazcb](https://github.com/chazcb). 279 | * Removed Google, Yahoo, and Bing from the default `USER_AGENTS`, since they now support the escaped fragment protocol (and leaving them in can cause a cloaking penalty.) Thanks to [thoop](https://github.com/thoop) for pointing this out. 280 | 281 | ### 0.3.0 - Feb 5, 2015 282 | 283 | * Fixes to the `update_url` method, thanks to [denisvlr](https://github.com/denisvlr). 284 | * Optimizations in lookups, thanks to [mattrobenolt](https://github.com/mattrobenolt). 285 | * Changes behavior to more sanely not follow redirects, per [#9](https://github.com/skoczen/django-seo-js/issues/9), thanks to [denisvlr](https://github.com/denisvlr) and [mattrobenolt](https://github.com/mattrobenolt). 286 | 287 | ### 0.2.4 - August 12, 2014 288 | 289 | * Adds a few more user agents to the defaults, per #7, and the suggestion of [andrewebdev](https://github.com/andrewebdev) 290 | 291 | ### 0.2.3 - May 28, 2014 292 | 293 | * Adds an optional `SEO_JS_IGNORE_EXTENSIONS` setting that contains a list of extensions to ignore, thanks to the suggestion by [thoop](https://github.com/thoop). 294 | 295 | ### 0.2.2 - May 22, 2014 296 | 297 | * Adds an optional `SEO_JS_IGNORE_URLS` setting, that contains a list of urls to ignore, thanks to the sitemap.xml prerender bug reported by [alex-mcleod](https://github.com/alex-mcleod). 298 | 299 | ### 0.2.1 - May 19, 2014 300 | 301 | * **Backwards incompatible** changes to `SEOBackendBase` - all backends are now expected to return an `HttpResponse` for their `get_response_for_url` methods. If you have custom backends, they'll need to be updated. All included backends have been updated, so if you're using an included backend, you can just pip install the new version, and go. 302 | * Returns pages that come back from the cache with anything besides a `5xx` status code. 303 | * Passes on headers, content type, and status code from the cache response. 304 | * If the backend return a `5xx` status, just returns the normal app and hopes for the best. 305 | 306 | 307 | ### 0.1.3 - May 13, 2014 308 | 309 | * Adds a `SEO_JS_ENABLED` setting, so you can disable hooks and middlewares during tests. 310 | 311 | 312 | ### 0.1.2 - May 9, 2014 313 | 314 | * Handles cases where a request didn't come with a User-agent. 315 | 316 | 317 | ### 0.1.1 - May 9, 2014 318 | 319 | * Improvements to unit tests. 320 | 321 | 322 | ### 0.1 - May 8, 2014 323 | 324 | * Includes `PrerenderIO` and `PrerenderHosted` backends. 325 | * First release - we're using this in production at [GreenKahuna ScrapBin](http://scrapbin.com). 326 | 327 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | override: 3 | - pip install -r requirements.tests.txt 4 | 5 | test: 6 | override: 7 | - python manage.py test 8 | -------------------------------------------------------------------------------- /django_seo_js/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = "0.4.1" 2 | -------------------------------------------------------------------------------- /django_seo_js/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import SelectedBackend, SelectedBackendMixin, SEOBackendBase 2 | from .prerender import PrerenderHosted, PrerenderIO 3 | from .test import TestBackend, TestServiceDownBackend 4 | -------------------------------------------------------------------------------- /django_seo_js/backends/base.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import requests 3 | from django.http import HttpResponse 4 | from django_seo_js import settings 5 | 6 | try: 7 | from django.utils.deprecation import MiddlewareMixin 8 | except ImportError: 9 | MiddlewareMixin = object 10 | 11 | 12 | IGNORED_HEADERS = frozenset(( 13 | 'connection', 'keep-alive', 'proxy-authenticate', 14 | 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 15 | 'upgrade', 'content-length', 'content-encoding' 16 | )) 17 | 18 | 19 | class SelectedBackendMixin: 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | module_path = settings.BACKEND 23 | backend_module = importlib.import_module(".".join(module_path.split(".")[:-1])) 24 | self.backend = getattr(backend_module, module_path.split(".")[-1])() 25 | 26 | 27 | class SelectedBackend(MiddlewareMixin, SelectedBackendMixin): 28 | pass 29 | 30 | 31 | class SEOBackendBase: 32 | """The base class to inherit for SEO_JS backends""" 33 | 34 | def build_absolute_uri(self, request): 35 | """ 36 | Return the fully-qualified url that will be pre-rendered. 37 | 38 | Override to customize how your URIs are built. 39 | 40 | e.g. to strip out all query params: 41 | ``` 42 | return '{scheme}://{host}{path}'.format( 43 | scheme=self.scheme, 44 | host=self.get_host(), 45 | path=self.path, 46 | ) 47 | ``` 48 | """ 49 | return request.build_absolute_uri() 50 | 51 | def get_response_for_url(self, url, request=None): 52 | """ 53 | Accepts a fully-qualified url. 54 | Returns an HttpResponse, passing through all headers and the status code. 55 | """ 56 | raise NotImplementedError 57 | 58 | def update_url(self, url): 59 | """ 60 | Force an update of the cache for a particular URL. 61 | Returns True on success, False on fail. 62 | """ 63 | raise NotImplementedError 64 | 65 | 66 | class RequestsBasedBackend(object): 67 | 68 | def __init__(self, *args, **kwargs): 69 | super(RequestsBasedBackend, self).__init__(*args, **kwargs) 70 | self.session = requests.Session() 71 | 72 | def build_django_response_from_requests_response(self, response): 73 | r = HttpResponse(response.content) 74 | for k, v in response.headers.items(): 75 | if k.lower() not in IGNORED_HEADERS: 76 | r[k] = v 77 | r['content-length'] = len(response.content) 78 | r.status_code = response.status_code 79 | return r 80 | -------------------------------------------------------------------------------- /django_seo_js/backends/prerender.py: -------------------------------------------------------------------------------- 1 | from django_seo_js import settings 2 | from .base import SEOBackendBase, RequestsBasedBackend 3 | 4 | 5 | class PrerenderIO(SEOBackendBase, RequestsBasedBackend): 6 | """Implements the backend for prerender.io""" 7 | BASE_URL = "https://service.prerender.io/" 8 | RECACHE_URL = "https://api.prerender.io/recache" 9 | 10 | def __init__(self, *args, **kwargs): 11 | super(SEOBackendBase, self).__init__(*args, **kwargs) 12 | self.token = self._get_token() 13 | 14 | def _get_token(self): 15 | if settings.PRERENDER_TOKEN is None: 16 | raise ValueError("Missing SEO_JS_PRERENDER_TOKEN in settings.") 17 | return settings.PRERENDER_TOKEN 18 | 19 | def get_response_for_url(self, url, request=None): 20 | """ 21 | Accepts a fully-qualified url. 22 | Returns an HttpResponse, passing through all headers and the status code. 23 | """ 24 | 25 | if not url or "//" not in url: 26 | raise ValueError("Missing or invalid url: %s" % url) 27 | 28 | render_url = self.BASE_URL + url 29 | headers = { 30 | 'X-Prerender-Token': self.token, 31 | } 32 | if request and settings.SEND_USER_AGENT: 33 | headers.update({'User-Agent': request.META.get('HTTP_USER_AGENT')}) 34 | 35 | r = self.session.get(render_url, headers=headers, allow_redirects=False) 36 | assert r.status_code < 500 37 | 38 | return self.build_django_response_from_requests_response(r) 39 | 40 | def update_url(self, url=None, regex=None): 41 | """ 42 | Accepts a fully-qualified url, or regex. 43 | Returns True if successful, False if not successful. 44 | """ 45 | 46 | if not url and not regex: 47 | raise ValueError("Neither a url or regex was provided to update_url.") 48 | 49 | headers = { 50 | 'X-Prerender-Token': self.token, 51 | 'Content-Type': 'application/json', 52 | } 53 | data = { 54 | 'prerenderToken': settings.PRERENDER_TOKEN, 55 | } 56 | if url: 57 | data["url"] = url 58 | if regex: 59 | data["regex"] = regex 60 | 61 | r = self.session.post(self.RECACHE_URL, headers=headers, data=data) 62 | return r.status_code < 500 63 | 64 | 65 | class PrerenderHosted(PrerenderIO): 66 | """Implements the backend for an arbitrary prerender service 67 | specified in settings.SEO_JS_PRERENDER_URL""" 68 | 69 | def __init__(self, *args, **kwargs): 70 | super(SEOBackendBase, self).__init__(*args, **kwargs) 71 | self.token = "" 72 | if not settings.PRERENDER_URL: 73 | raise ValueError("Missing SEO_JS_PRERENDER_URL in settings.") 74 | if not settings.PRERENDER_RECACHE_URL: 75 | raise ValueError("Missing SEO_JS_PRERENDER_RECACHE_URL in settings.") 76 | 77 | self.BASE_URL = settings.PRERENDER_URL 78 | self.RECACHE_URL = settings.PRERENDER_RECACHE_URL 79 | 80 | def _get_token(self): 81 | pass 82 | 83 | def update_url(self, url=None): 84 | """ 85 | Accepts a fully-qualified url. 86 | Returns True if successful, False if not successful. 87 | """ 88 | if not url: 89 | raise ValueError("Neither a url or regex was provided to update_url.") 90 | post_url = "%s%s" % (self.BASE_URL, url) 91 | r = self.session.post(post_url) 92 | return int(r.status_code) < 500 93 | -------------------------------------------------------------------------------- /django_seo_js/backends/test.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from .base import SEOBackendBase 4 | 5 | 6 | class TestBackend(SEOBackendBase): 7 | """Implements a test backend""" 8 | 9 | def get_response_for_url(self, url, request=None): 10 | r = HttpResponse("Test") 11 | r["test rendered"] = "headers" 12 | r.status_code = 200 13 | r.content_type = "text/html" 14 | return r 15 | 16 | def update_url(self, url): 17 | return True 18 | 19 | 20 | class TestServiceDownBackend(SEOBackendBase): 21 | """Implements a test backend""" 22 | 23 | def get_response_for_url(self, url, request=None): 24 | r = HttpResponse("Service Down") 25 | r["test rendered"] = "headers" 26 | r.status_code = 503 27 | r.content_type = "text/html" 28 | assert r.status_code < 500 29 | 30 | def update_url(self, url): 31 | return False 32 | -------------------------------------------------------------------------------- /django_seo_js/helpers.py: -------------------------------------------------------------------------------- 1 | from django_seo_js import settings 2 | from django_seo_js.backends import SelectedBackendMixin 3 | 4 | 5 | def update_cache_for_url(url): 6 | if settings.ENABLED: 7 | selector = SelectedBackendMixin() 8 | return selector.backend.update_url(url) 9 | return False 10 | 11 | 12 | def request_should_be_ignored(request): 13 | for url in settings.IGNORE_URLS: 14 | if url in request.path: 15 | return True 16 | 17 | extension = None 18 | last_dot = request.path.rfind(".") 19 | if last_dot == -1: 20 | # No extension found 21 | return False 22 | 23 | extension = request.path[last_dot:] 24 | return extension and extension in settings.IGNORE_EXTENSIONS 25 | -------------------------------------------------------------------------------- /django_seo_js/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from .escaped_fragment import EscapedFragmentMiddleware 2 | from .hashbang import HashBangMiddleware 3 | from .useragent import UserAgentMiddleware 4 | -------------------------------------------------------------------------------- /django_seo_js/middleware/escaped_fragment.py: -------------------------------------------------------------------------------- 1 | from django_seo_js import settings 2 | from django_seo_js.backends import SelectedBackend 3 | from django_seo_js.helpers import request_should_be_ignored 4 | 5 | import logging 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class EscapedFragmentMiddleware(SelectedBackend): 10 | def process_request(self, request): 11 | if not settings.ENABLED: 12 | return 13 | 14 | if request_should_be_ignored(request): 15 | return 16 | 17 | if "_escaped_fragment_" not in request.GET: 18 | return 19 | 20 | url = self.backend.build_absolute_uri(request) 21 | try: 22 | return self.backend.get_response_for_url(url, request) 23 | except Exception as e: 24 | logger.exception(e) 25 | 26 | 27 | class HashBangMiddleware(EscapedFragmentMiddleware): 28 | 29 | def __init__(self, *args, **kwargs): 30 | logging.info( 31 | "Deprecation note: HashBangMiddleware has been renamed EscapedFragmentMiddleware," 32 | " for more clarity. Upgrade your MIDDLEWARE_CLASSES to \n" 33 | " 'django_seo_js.middleware.EscapedFragmentMiddleware'" 34 | " when you get a chance. HashBangMiddleware will be removed in v0.5" 35 | ) 36 | super(HashBangMiddleware, self).__init__(*args, **kwargs) 37 | -------------------------------------------------------------------------------- /django_seo_js/middleware/hashbang.py: -------------------------------------------------------------------------------- 1 | from .escaped_fragment import HashBangMiddleware 2 | 3 | import logging 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /django_seo_js/middleware/useragent.py: -------------------------------------------------------------------------------- 1 | import re 2 | from django_seo_js import settings 3 | from django_seo_js.backends import SelectedBackend 4 | from django_seo_js.helpers import request_should_be_ignored 5 | 6 | import logging 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class UserAgentMiddleware(SelectedBackend): 11 | def __init__(self, *args, **kwargs): 12 | super(UserAgentMiddleware, self).__init__(*args, **kwargs) 13 | regex_str = "|".join(settings.USER_AGENTS) 14 | regex_str = ".*?(%s)" % regex_str 15 | self.USER_AGENT_REGEX = re.compile(regex_str, re.IGNORECASE) 16 | 17 | def process_request(self, request): 18 | if not settings.ENABLED: 19 | return 20 | 21 | if request_should_be_ignored(request): 22 | return 23 | 24 | if "HTTP_USER_AGENT" not in request.META: 25 | return 26 | 27 | if not self.USER_AGENT_REGEX.match(request.META["HTTP_USER_AGENT"]): 28 | return 29 | 30 | url = self.backend.build_absolute_uri(request) 31 | try: 32 | return self.backend.get_response_for_url(url, request) 33 | except Exception as e: 34 | logger.exception(e) 35 | -------------------------------------------------------------------------------- /django_seo_js/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | 3 | ENABLED = getattr(django_settings, 'SEO_JS_ENABLED', not django_settings.DEBUG) 4 | 5 | IGNORE_URLS = frozenset(getattr(django_settings, 'SEO_JS_IGNORE_URLS', ['/sitemap.xml'])) 6 | 7 | IGNORE_EXTENSIONS = frozenset(getattr(django_settings, 'SEO_JS_IGNORE_EXTENSIONS', ( 8 | ".js", 9 | ".css", 10 | ".xml", 11 | ".less", 12 | ".png", 13 | ".jpg", 14 | ".jpeg", 15 | ".gif", 16 | ".pdf", 17 | ".doc", 18 | ".txt", 19 | ".ico", 20 | ".rss", 21 | ".zip", 22 | ".mp3", 23 | ".rar", 24 | ".exe", 25 | ".wmv", 26 | ".doc", 27 | ".avi", 28 | ".ppt", 29 | ".mpg", 30 | ".mpeg", 31 | ".tif", 32 | ".wav", 33 | ".mov", 34 | ".psd", 35 | ".ai", 36 | ".xls", 37 | ".mp4", 38 | ".m4a", 39 | ".swf", 40 | ".dat", 41 | ".dmg", 42 | ".iso", 43 | ".flv", 44 | ".m4v", 45 | ".torrent", 46 | ))) 47 | 48 | USER_AGENTS = frozenset(getattr(django_settings, 'SEO_JS_USER_AGENTS', ( 49 | "Googlebot", 50 | "Yahoo", 51 | "bingbot", 52 | "Ask Jeeves", 53 | "baiduspider", 54 | "facebookexternalhit", 55 | "twitterbot", 56 | "rogerbot", 57 | "linkedinbot", 58 | "embedly", 59 | "quoralink preview'", 60 | "quora link preview", 61 | "showyoubot", 62 | "outbrain", 63 | "pinterest", 64 | "developersgoogle.com/+/web/snippet", 65 | "yandex", 66 | "slackbot", 67 | "vkshare", 68 | "w3c_validator", 69 | "redditbot", 70 | "applebot", 71 | "whatsapp", 72 | "flipboard", 73 | "tumblr", 74 | "bitlybot", 75 | "skypeuripreview", 76 | "nuzzel", 77 | "discordbot", 78 | "google page speed", 79 | "qwantify", 80 | "pinterestbot", 81 | "bitrix link preview", 82 | "xing-contenttabreceiver", 83 | "chrome-lighthouse", 84 | "telegrambot", 85 | ))) 86 | 87 | BACKEND = getattr(django_settings, 'SEO_JS_BACKEND', 'django_seo_js.backends.PrerenderIO') 88 | 89 | PRERENDER_TOKEN = getattr(django_settings, 'SEO_JS_PRERENDER_TOKEN', None) 90 | PRERENDER_URL = getattr(django_settings, 'SEO_JS_PRERENDER_URL', None) 91 | PRERENDER_RECACHE_URL = getattr(django_settings, 'SEO_JS_PRERENDER_RECACHE_URL', None) 92 | 93 | SEND_USER_AGENT = getattr(django_settings, 'SEO_JS_SEND_USER_AGENT', True) 94 | -------------------------------------------------------------------------------- /django_seo_js/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skoczen/django-seo-js/fb2cf050abf7f38dfc67f325d7b448f8fef0b2e2/django_seo_js/templatetags/__init__.py -------------------------------------------------------------------------------- /django_seo_js/templatetags/django_seo_js.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.simple_tag 8 | def seo_js_head(*args): 9 | return mark_safe("""""") 10 | -------------------------------------------------------------------------------- /django_seo_js/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skoczen/django-seo-js/fb2cf050abf7f38dfc67f325d7b448f8fef0b2e2/django_seo_js/tests/__init__.py -------------------------------------------------------------------------------- /django_seo_js/tests/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skoczen/django-seo-js/fb2cf050abf7f38dfc67f325d7b448f8fef0b2e2/django_seo_js/tests/backends/__init__.py -------------------------------------------------------------------------------- /django_seo_js/tests/backends/test_base.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_seo_js.tests.utils import override_settings, get_response_empty 4 | from django_seo_js.backends import PrerenderIO, SelectedBackend, SelectedBackendMixin, SEOBackendBase, TestBackend 5 | 6 | 7 | class SEOBackendBaseTest(TestCase): 8 | def setUp(self): 9 | self.backend = SEOBackendBase() 10 | 11 | def test_get_response_for_url(self): 12 | self.assertRaises(NotImplementedError, self.backend.get_response_for_url, "http://www.example.com") 13 | 14 | def test_update_url(self): 15 | self.assertRaises(NotImplementedError, self.backend.update_url, "http://www.example.com") 16 | 17 | 18 | class SelectedBackendTest(TestCase): 19 | 20 | def test_default_backend(self): 21 | s_mixin = SelectedBackendMixin() 22 | s = SelectedBackend(get_response_empty) 23 | self.assertTrue(isinstance(s_mixin.backend, PrerenderIO)) 24 | self.assertTrue(isinstance(s.backend, PrerenderIO)) 25 | 26 | @override_settings(BACKEND='django_seo_js.backends.TestBackend') 27 | def test_override_backend(self): 28 | s_mixin = SelectedBackendMixin() 29 | s = SelectedBackend(get_response_empty) 30 | self.assertTrue(isinstance(s_mixin.backend, TestBackend)) 31 | self.assertTrue(isinstance(s.backend, TestBackend)) 32 | -------------------------------------------------------------------------------- /django_seo_js/tests/backends/test_prerender_hosted.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import random 4 | import string 5 | 6 | from django.test import TestCase 7 | from httmock import all_requests, HTTMock 8 | 9 | from django_seo_js.tests.utils import override_settings 10 | from django_seo_js.backends import PrerenderHosted 11 | 12 | MOCK_RESPONSE = b"