DEV Community

Cover image for Tracking Twitter User Behavior - The Browser Extension
Alexander Girke
Alexander Girke

Posted on

Tracking Twitter User Behavior - The Browser Extension

Hello๐Ÿ‘‹,

today we're gonna have a look at the first component of my data collection pipeline: the browser extension. If you want to peek into the code, find it here.

Intro

First, some background: what I needed was a browser extension, which runs on as many browsers as possible without having too much of a hassle supporting all of them. Luckily, Chrome (and all other Chromium-based browsers like Opera, Edge or Brave) and Firefox basically share the same API, called WebExtensions API. I won't go into much detail, so if you're not familiar with how browser extensions look like and what they can do, check out this awesome post:

The resulting manifest.json looks like this:

{
  "manifest_version": 2,
  "name": "Twitter Tracking Extension",
  "version": "$VERSION",
  "description": "A browser extension that captures Twitter clicks - for scientific reasons.",
  "homepage_url": "https://github.com/alxgrk/twitter-tracking",
  "browser_specific_settings": {
    "gecko": {
      "id": "twittertracking@university.de",
      "strict_min_version": "48.0",
      "update_url": "https://github.com/alxgrk/twitter-tracking/blob/master/twitter-tracking-browser-extension/firefox-addon-update.json"
    }
  },

  "permissions": [
    "storage",
    "unlimitedStorage",
    "$ACCESS_SITE_PERMISSION"
  ],

  "icons": {
    "48": "icons/icon-48.png",
    "96": "icons/icon-96.png"
  },

  "browser_action": {
    "default_icon": {
      "32" : "icons/icon-32.png"
    },
    "default_title": "Twitter Tracking",
    "default_popup": "popup/tracked-events.html"
  },

  "content_scripts": [
    {
      "matches": ["*://*.twitter.com/*"],
      "js": ["js/main.js"]
    }
  ]

}
Enter fullscreen mode Exit fullscreen mode

Note the variables starting with $...: they are going to be replaced by webpack. We'll see how in the next section.

The Building Process

First, let's have a look at how the final artifacts are build. I'm using Webpack's copy-webpack-plugin and webpack.DefinePlugin to differentiate when building for dev and prod. The CopyWebpackPlugin enables you to move third-party dependencies where needed or replace placeholders in files like the manifest.json. The following snippet shows how to set version and access site permission & bundle the webextension-polyfills necessary to ensure interoperability between Chrome and Firefox for production:

new CopyWebpackPlugin({
            patterns: [
                {
                    from: 'src',
                    globOptions: {
                        ignore: ['**/*.js'],
                    },
                    transform(content, absoluteFrom) {
                        if (absoluteFrom.includes("manifest.json")) {
                            return content.toString()
                                .replace("$VERSION", process.env.npm_package_version)
                                .replace("$ACCESS_SITE_PERMISSION", dotenv.parsed.PROD_API_URL);
                        } else {
                            return content
                        }
                    }
                }, {
                    from: 'node_modules/webextension-polyfill/dist/browser-polyfill.js',
                    to: path.resolve(distRoot, 'js')
                }
            ]
        }
Enter fullscreen mode Exit fullscreen mode

To set environment variables like the endpoint of the REST-API, DefinePlugin has to be used as follows:

new webpack.DefinePlugin({
            'process.env.NODE_ENV': '"production"',
            API: JSON.stringify(dotenv.parsed.PROD_API_URL)
        })
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, the value for the API comes from a variable named dotenv, which is also the name of the library, that reads a .env file from your current working directory and provides its values as JS objects. To optimize the production artifact, I also added the TerserPlugin like this:

optimization: {
        minimize: true,
        minimizer: [new TerserPlugin()],
    }
Enter fullscreen mode Exit fullscreen mode

Since we now have everything ready to build our artifacts, we need to make sure that Chrome, Firefox, etc. permit their installation. To achieve this, we need two different tools: web-ext for Firefox and chromium for Chrome. Check out this script for an example on how to use them. Also, make sure to follow the instructions on publishing extensions for Firefox and for Chrome.

The Collection Process

Once the user has the extension installed, the content-script located at src/main.js runs as soon as the user visits Twitter. And now the funny stuff begins.

What I did was to register some listeners to the standard browser events like load, beforeunload,click or scroll. If you remember the model from the previous post, we now need to deduct from these events what temporal dimension and what kind of user interactions we have.

load and beforeunload give us the start and end events, which are going to be published to the backend and additionally stored in local storage for the user to be displayed later. While constructing the events, we also have to create some kind of identifier for the user, ideally a reproducible one if we want to relate his/her Android app interactions. To achieve this, the username has to be found by inspecting the alt attribute of the user's profile image. And since we care (at least a little) about privacy, a consistent and "secure" hashing method like SHA256 is applied to it.

scroll maps trivially to the scroll events needed. Just use document.documentElement.scrollTop to get the current scroll position.

Concerning clicks, however, a little more fiddling is required. Unfortunately, Twitter HTML tags are almost all divs with randomized classnames. The only anchor are semantic tags like article, rare element IDs and the attribute data-testid. Luckily, with these and a great tool named @medv/finder, I was able to create RegExes that match the found selector to my predefined click types. So for any click events, the finder is run...

let selector = finder(event.target, {
        className: name => false // do not rely on classnames
    });
Enter fullscreen mode Exit fullscreen mode

...and the result is mapped. All mapping functions are stored in an object, whose keys are the names of the click type. For e.g. a like click, this is the RegEx:

const TARGETS = {
    like: selector => /#tweet-action-buttons.+:nth-child\(3\).*/.test(selector),
    ...
}
Enter fullscreen mode Exit fullscreen mode

As you can imagine, this approach is quite error-prone. But that is one of the difficulties when reverse-engineering a constantly changing external system and I haven't found an alternative yet. If you know it better, please comment.

Thanks for reading and have fun until next time when the post about the Android app is finished. โœŒ๏ธ

Top comments (0)