Tracking Twitter User Behavior - The Browser Extension
Tags: twitter, browser, extension, javascript
Published: 2021-02-01 08:20:20

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](https://github.com/alxgrk/twitter-tracking/tree/master/twitter-tracking-browser-extension).

# 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](https://developer.chrome.com/docs/extensions/reference/) (and all other Chromium-based browsers like [Opera](https://www.opera.com/de), [Edge](https://www.microsoft.com/en-us/edge) or [Brave](https://brave.com/)) and [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API) 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:
{% link https://dev.to/ganeshjaiwal/build-your-first-chrome-extension-458g %}

The resulting manifest.json looks like this:
```json
{
  "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"]
    }
  ]
 
}
```

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:
```js
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')
                }
            ]
        }
```
To set environment variables like the endpoint of the REST-API, DefinePlugin has to be used as follows:
```js
new webpack.DefinePlugin({
            'process.env.NODE_ENV': '"production"',
            API: JSON.stringify(dotenv.parsed.PROD_API_URL)
        })
```
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:
```js
optimization: {
        minimize: true,
        minimizer: [new TerserPlugin()],
    }
```

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](https://github.com/alxgrk/twitter-tracking/blob/master/twitter-tracking-browser-extension/bundle-for-firefox-and-chrome.sh) for an example on how to use them. Also, make sure to follow the instructions on publishing extensions for [Firefox](https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/) and for [Chrome](https://developer.chrome.com/docs/webstore/publish/).

# 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](https://dev.to/alxgrk/tracking-twitter-user-behavior-the-model-44cf), 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 `click`s, however, a little more fiddling is required. Unfortunately, Twitter HTML tags are almost all `div`s 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`](https://github.com/antonmedv/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...
```js
let selector = finder(event.target, {
        className: name => false // do not rely on classnames
    });
```
...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:
```js
const TARGETS = {
    like: selector => /#tweet-action-buttons.+:nth-child\(3\).*/.test(selector),
    ...
}
```

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. ✌️