├── .gitignore ├── README.md ├── app.py ├── html └── index.html ├── requirements.txt ├── static ├── img │ └── demo.gif └── js │ └── playback.js └── tracker.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | .DS_STORE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ### CSS-Only Cursor Tracking 3 | 4 | ![Demo](static/img/demo.gif "cursor tracking in action") 5 | 6 | The goal of this project is evaluate the efficacy of cursor tracking utilizing only CSS. The code in this repo is merely a PoC and is purely for experimental purposes. 7 | 8 | ### Method 9 | 10 | Here I describe the method utilized here to accomplishing some degree of cursor tracking: 11 | 12 | 1) Generate a CSS tracking file by first traversing over the DOM of a target html document, crafting selectors that uniquely select each tag on the page by chaining nth-child selectors 13 | 14 | 2) Define a unique set of keyframes per tag, mapped over a duration and keyframe count (these can be adjusted). Most importantly, each keyframe makes a request using the `url()` function, passing the selector and current duration of time within the url 15 | 16 | 3) Initially, set animation-play-state of the elements to `paused` but set to `running` for the pseudo-class hover selector 17 | 18 | 4) On the backend, process each request by mapping selector -> cursor hover time as well as a list of timestamped events in chronological order. 19 | 20 | 5) Using all this data, one is able to playback the motion of a cursor across the page with decent accuracy. 21 | 22 | ### Setup 23 | 24 | Install Packages: ` sudo pip install -r requirements.txt` 25 | 26 | Or use a virtual environment: 27 | 28 | ` virtualenv -p python3 venv ` 29 | 30 | ` source venv/bin/activate ` 31 | 32 | ` pip install -r requirements.txt ` 33 | 34 | 35 | 36 | Place target html page in the html directory and name it `index.html` 37 | 38 | `python tracker.py html/index.html` will produce a `tracker.css` file in `static/css` 39 | 40 | `FLASK_APP=app.py flask run` 41 | 42 | Go to http://127.0.0.1:5000/ to see the index.html; move you cursor around to send data to the flask server 43 | 44 | After that, go to http://127.0.0.1:5000/results/ to see your actions played back to you. 45 | 46 | Whenever you wish to clear you session, just run http://127.0.0.1:5000/clear/ 47 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import time 2 | import urllib.parse 3 | from flask import Flask, session, render_template, Response, request, send_from_directory 4 | 5 | import logging 6 | log = logging.getLogger('werkzeug') 7 | log.setLevel(logging.ERROR) 8 | 9 | app = Flask(__name__, static_url_path='/static') 10 | app.secret_key = 'SECRET_KEY' 11 | 12 | @app.route('/') 13 | def root(): 14 | return Response(get_file_and_format('html/index.html',''), 15 | mimetype='text/html') 16 | #return render_template('index.html', import_line='') 17 | 18 | @app.route('/tracker///') 19 | def tracker(selector, duration): 20 | selector = urllib.parse.unquote(selector) 21 | session[selector] = float(duration) 22 | 23 | if session[selector] == 0: 24 | return 'OK' 25 | 26 | if 'events' not in session: 27 | session['events'] = [] 28 | 29 | timestamp = int(round(time.time() * 1000)) 30 | session['events'].append({'timestamp': timestamp, 'selector': selector}) 31 | print('[%s] Hovered on element matching selector %s\nTotal Duration: %s' % (timestamp, selector, duration)) 32 | 33 | return 'OK' 34 | 35 | @app.route('/results/') 36 | def results(): 37 | events = session.get('events', []) 38 | return Response(get_file_and_format('html/index.html', ' '.format(str(events))), 39 | mimetype='text/html') 40 | #return render_template('index.html', 41 | # import_line=' '.format(str(events))) 42 | 43 | @app.route('/clear/') 44 | def clear(): 45 | session.clear() 46 | return 'OK' 47 | 48 | def get_file_and_format(path, content): 49 | with open(path) as f: 50 | return f.read().replace('{css_tracker_import_line}', content) -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {css_tracker_import_line} 4 | Example Domain 5 | 6 | 7 | 8 | 9 | 40 | 41 | 42 | 43 |
44 |

Example Domain

45 |

This domain is established to be used for illustrative examples in documents. You may use this 46 | domain in examples without prior coordination or asking for permission.

47 |

More information...

48 |
49 | 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | bs4 4 | -------------------------------------------------------------------------------- /static/img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaytoun/cursor-tracking-css/b1f0ddbe7f44aded397b53fbf248a859bfaeb04c/static/img/demo.gif -------------------------------------------------------------------------------- /static/js/playback.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function(event) { 2 | 3 | function sleep(ms) { 4 | return new Promise(resolve => setTimeout(resolve, ms)); 5 | } 6 | 7 | async function playback(events) { 8 | for (var i = 0; i < events.length - 1; i++) { 9 | el = document.querySelector(events[i].selector); 10 | el.style.background = 'rgba(255, 0, 0, .5)'; 11 | let difference = events[i+1].timestamp - events[i].timestamp; 12 | await sleep(difference); 13 | el.style.background = null; 14 | } 15 | } 16 | 17 | playback(events) 18 | 19 | }) -------------------------------------------------------------------------------- /tracker.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import string 4 | import random 5 | import hashlib 6 | import urllib.parse 7 | from bs4 import BeautifulSoup 8 | 9 | def generate_css(path, duration=100, keyframe_count=100): 10 | inject_template_tag(path) 11 | 12 | with open(path, 'r') as f: 13 | soup = BeautifulSoup(f.read(), 'html.parser') 14 | 15 | selectors = [] 16 | generate_tag_selectors(soup, selectors, '') 17 | 18 | if not os.path.exists('./static/css/'): 19 | os.makedirs('./static/css/') 20 | 21 | with open('static/css/tracker.css', 'w') as f: 22 | for selector in selectors: 23 | animation_name = ''.join(random.choices(string.ascii_lowercase, k=6)) 24 | f.write(generate_animation_keyframes(selector, animation_name, duration, keyframe_count)) 25 | f.write(generate_animation_rule(selector, animation_name, duration)) 26 | f.write(generate_hover_rule(selector)) 27 | 28 | def generate_hover_rule(selector): 29 | return '{selector}:hover {{ -webkit-animation-play-state:running; -moz-animation-play-state:running; animation-play-state:running; }}\n'.format(selector=selector) 30 | 31 | def generate_animation_rule(selector, animation_name, duration): 32 | return '{selector} {{ -moz-animation: {animation_name} {duration}s infinite; -webkit-animation: {animation_name} {duration}s infinite; animation: {animation_name} {duration}s infinite; -webkit-animation-play-state:paused; -moz-animation-play-state:paused; animation-play-state:paused; }}\n'.format(selector=selector, animation_name=animation_name, duration=duration) 33 | 34 | def generate_animation_keyframes(selector, animation_name, duration, keyframe_count): 35 | keyframe_count = max(1, min(10000, keyframe_count)) 36 | step_size = 100.00 / keyframe_count 37 | time_per_step = float(duration) / keyframe_count 38 | 39 | keyframes = [] 40 | i = 0 41 | time = 0 42 | while i < 100: 43 | percentage, t = '{0:.2f}'.format(i), '{0:.2f}'.format(time) 44 | keyframes.append('%s%% { background-image: url("http://127.0.0.1:5000/tracker/%s/%s/"); }' % (percentage, urllib.parse.quote(selector), t)) 45 | i += step_size 46 | time += time_per_step 47 | 48 | return '@keyframes %s { %s }\n' % (animation_name, ' '.join(keyframes)) 49 | 50 | def inject_template_tag(path): 51 | with open(path, 'r') as f: 52 | html = f.read() 53 | 54 | if html.find('{css_tracker_import_line}') > -1: 55 | return 56 | 57 | html = html.replace('', '{css_tracker_import_line}') 58 | 59 | with open(path, 'w') as f: 60 | f.write(html) 61 | 62 | def generate_tag_selectors(s, selectors, selector): 63 | if getattr(s, 'name', None) == None: 64 | return 65 | 66 | if selector: 67 | selectors.append(selector) 68 | 69 | if hasattr(s, 'children'): 70 | i = 1 71 | for child in s.children: 72 | if getattr(child, 'name', None) == None: 73 | continue 74 | next_selector = selector + ' :nth-child(%s)' % (i) 75 | generate_tag_selectors(child, selectors, selector=next_selector) 76 | i += 1 77 | 78 | def main(): 79 | if len(sys.argv) != 2: 80 | print('please include the path of the html file as an arg') 81 | return 82 | generate_css(sys.argv[1]) 83 | 84 | if __name__ == '__main__': 85 | main() 86 | --------------------------------------------------------------------------------