Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adblock: Support cosmetic filtering (element hiding) and scriptlets #7629

Open
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

alkim0
Copy link

@alkim0 alkim0 commented Mar 19, 2023

This pull request contains the implementation for cosmetic filtering and scriptlet injection (after the qt6 branch merge). Closes #6480.

The main body of the code for this is spread over the two files: components/braveadblock.py, and components/ublock_resources.py.

Note that these changes work only for WebEngine.

Cosmetic filtering is implemented in two steps. First, before the page loads, the Brave ad blocker engine is queried for URL-specific filters that should be applied. The user-specified css (using stylesheet.js) is then updated to include these filters via the newly added add_dynamic_css function. Next, after the page loads, an asynchronous javascript function gets the list of classes and ids generated on the current page. The callback to this function calls the Brave ad blocker with this information and applies the returned generic filters, once again using the add_dynamic_css function. To facilitate this, the following APIs have been added: api.hook.before_loaded, api.hook.load_started, api.hook.load_finished, api.Tab.add_dynamic_css, api.Tab.remove_dynamic_css. In addition, the change mentioned in the critical changes section was implemented.

For scriptlet injection, in addition to the filter lists, additional javascript templates/functions need to be downloaded and added to the Brave ad blocker before working properly. The :adblock-update-resources command was added to perform this action. The code for this mostly lies in components/ublock_resources.py and takes heavy inspiration inspiration from the original Brave ad blocker implementation (specifically https://github.com/brave/adblock-rust/blob/master/src/resources/resource_assembler.rs). It downloads the resources specified by ublock's redirect-resources.js as well ublock's scriptlets.js and caches them. These resources are not expected to change as often as the filter lists, so they are managed separately. In addition, the urls are fairly unintuitive to the user, so they were not added as a config option, but rather hardcoded into the code.

The scriptlets are injected at the same time as the dynamic css (i.e., during the api.hook.before_loaded), and uses the newly added function add_web_script to do this. The following API changes have been made to support scriptlet injection: api.Tab.add_web_script, api.Tab.remove_web_script, api.usertypes.InjectionPoint.

Finally, a unit test regarding adblocker resources and cosmetic filtering has been added.

Critical Changes

In addition to being emitted during _load_url_prepare, the api.tab.Tab.before_load_started signal is now also emitted during _on_navigation_request. The reason for this is that _load_url_prepare is not always called before a new page loads. For example, this can happen when a non-clickable object is clicked to open in the background. In this case, TabbedBrowser.tabopen will be called with url=None, so load_url and _load_url_prepare are not called. However, _on_navigation_request is always called, and the url is always known by this point. For a reliable api.hook.before_loaded hook, it is essential we emit before_load_started during _on_navigation_request as well. We don't want to remove the emit from _load_url_prepare either, since that gets called before _on_navigation_request, and we need the hook to fire as soon as possible.

API Changes

api.hook.before_loaded

A hook that is guaranteed to be notified before a page loads. Components which need to do something before a page loads should use this hook. The hook may be called multiple times before a page loads. The api.Tab object which is about to load and the url which we are about to load are passed in as arguments.

api.hook.load_started

A hook that is guaranteed to be notified after the page loads. Components which need to do something after a page starts loading should use this hook. The api.Tab object which is starting to load is passed in as an argument.

api.hook.load_finished

A hook that is guaranteed to be notified after the page finishes loading. Components which need to do something after a page load finishes should use this hook. The api.Tab object which just finished loading and the success flag of whether the load was done successfully is passed in as arguments.

api.Tab.add_dynamic_css

Adds css which will get applied to every page the api.Tab object loads. This has lower precedence than the user-specified stylesheet in the user config. This css is expected to change often, hence the "dynamic".

api.Tab.remove_dynamic_css

Removes the aforementioned css which will get applied to every page the api.Tab object loads.

api.Tab.add_web_script

Adds a snippet of javascript to be executed during the api.Tab object's page load. The specific moment in which the web script executes can be controlled with api.usertypes.InjectionPoint. These "web scripts" are intentionally separated from the greasemonkey scripts. Greasemonkey scripts are added and managed by the end user, but these scripts are meant to be used by the components within qutebrowser. This interface also allows for more control over exactly how the added scripts will be executed.

api.Tab.remove_web_script

Removes the aforementioned snippet of javascript to be executed during the api.Tab object's page load.

api.usertypes.InjectionPoint

A type representing the point at which to execute an injected web script.

Other changes to core implementation

  • _update_stylesheet now also includes stylesheet.js in its injected script (wrapped in the global wrapper) because sometimes asynchronous code runs before injected scripts, and stylesheet.set_css will obviously fail if window._qutebrowser.stylesheet has not been specified.
  • Added additional javascript logging to scripts to denote when certain scripts are run (only if the current logging level is DEBUG).

Other considerations

  • Currently, querying the ad blocker for cosmetic filters is in the critical path. To some degree, this is necessary since scriptlets need to be injected upon document creation (otherwise they won't take effect on current page load).
  • Right now, the generic css is gathered and applied in one go at the end using the load_finished hook, to make this faster, we may want to have some javascript observe the DOM and update as we go along, but this likely requires QT's web channels.
  • Brave's ad blocker comes with a resource assembler which can help us get rid of a lot of the code in components/ublock_resources.py, but python-adblock does not have the interface implemented for that. In the future, if/when python-adblock does implement the interface, we should move the code out from components/ublock_resources.py, but for now, I did it this way just to get things working.

Qt 6

Note that this should work with both Qt 5 and Qt 6 (confirmed to be working with Qt 6.4).

@alkim0 alkim0 changed the title Adblock qt6 adblock: Support cosmetic filtering (element hiding) and scriptlets Mar 19, 2023
@toofar
Copy link
Member

toofar commented Mar 19, 2023

Since on that last PR you mentioned "I'm assuming this PR will not get merged before that" I though I would take the time to add some encouraging comments.

No, unfortunately, it's not going to get merged before 3.0 (which makes Qt6 the preferred backend) or even 4.0 (which'll drop webkit and do a bunch of other disruptive maintenance stuff). And even then once we are trying to get through the PR backlog I think this'll be a complicated one due to changes to the emerging extension API. I'm excited to have a concrete and well though out proposal for that API (you did make it onto my aspirational focus board) but while we are focusing on getting PRs merged I think it's going to be quite hard to plan for how we would like extensions to work with the core stuff at the same time. So having a PR that adds a feature and changes the ostensibly-public API makes it automatically more complex.

Anyway, I haven't actually looked at this PR except for a while back in February (I have notes, no idea what's in them) so feel free to tell me that you are already doing it the most correct way. But if you can see some way to make the api changes optional (eg isolated to one commit we can drop, or you can move to a separate PR) that would probably make this PR a bit less complex to review once we get around to it. That'll probably mean importing more core stuff into a component, which is probably fine. The adblock stuff had a bit of an easier time in that respect because it mostly uses the interceptor API.

@alkim0
Copy link
Author

alkim0 commented Mar 19, 2023

As requested, I've isolated the API changes to another PR (#7630) . Unfortunately, I can't just get rid of them since I need to be able access the api.Tab object to inject javascript and css at various points of the page load. The adblock stuff could deal with things purely at the network level, so the interceptor API was sufficient, but that's not the case for comsetic filtering + scriptlet injection I'm afraid.

Added a way to interact with the Tab instance before, after starting, and finishing
loading a page
First stab at cosmetic filtering with the python-adblock library.

Cosmetic filtering is done in two steps:
1. Lookup url-specific cosmetic selectors.
2. Lookup generic selectors based on the element classes and ids present in the page.

In our code, we perform step 1 in a load_started hook. The results are then translated
to javascript and inserted into the page using Tab.run_js_async. The injected javascript
also returns all the class names and ids that appear in the page. A callback then takes
this information and runs step 2.

Note that scriptlet injection is not yet available because extra resources need to be
downloaded and added to the engine before script injection resolves properly.
To add resources to the engine, run :adblock-update-resources. Then, the resources
should automatically download, cache, and add themselves to the engine.

The way the resources are determined is similar to rust Brave's adblocker code.
They download uBlock's redirect-engine.js, parse it, and select resources based on that.
They also parse and add the functions and function templates from uBlock's
scriptlets.js. We do basically the same thing.

Note that this version requires the version of python-adblock whose add_resource
function for the engine has 4 arguments (including aliases). This will likely be version
0.5.3.

Scriptlet injection seems to mostly work, but there are still some annoying caveats.
Because currently scriptlets are injected after the page is loaded, ads on youtube will
will not be blocked if the video is opened a new tab.
Also, used a lot of the convenience functions present in the qutebrowser codebase.

Everything seems to work, including scriptlets.

Also added unit test cases for cosmetic filtering.
To avoid having to do a :adblock-update-resources after every :adblock-update.
@duarm

This comment was marked as off-topic.

@duarm
Copy link

duarm commented Sep 5, 2023

After updating to HEAD (from an older, pre 3.0.0 version), got the cannot parse error

$ qutebrowser -T -d

16:46:31 DEBUG    commands   command:run:511 command called: adblock-update-resources
16:46:31 DEBUG    commands   command:run:525 Calling qutebrowser.components.adblockcommands.adblock_update_resources()
16:46:31 DEBUG    downloads  qtnetworkdownloads:fetch:537 fetch: PyQt6.QtCore.QUrl('https://github.com/gorhill/uBlock/raw/master/src/js/redirect-resources.js') -> redirect-resources.js
16:46:31 DEBUG    downloads  downloads:_on_begin_insert_row:1051 _on_begin_insert_row with idx 0, webengine False
16:46:31 DEBUG    downloads  qtnetworkdownloads:_set_fileobj:263 buffer: 0 bytes
16:46:31 DEBUG    modes      modeman:_handle_keypress:309 match: SequenceMatch.ExactMatch, forward_unbound_keys: auto, passthrough: True, is_non_alnum: True, dry_run: False --> filter: True (focused: <PyQt6.QtWidgets.QWidget object at 0x7fbfe81385e0, className='QQuickWidget'>)
16:46:31 DEBUG    modes      modeman:_handle_keyrelease:334 filter: True
16:46:31 DEBUG    downloads  qtnetworkdownloads:_on_redirected:168 redirected: PyQt6.QtCore.QUrl('https://raw.githubusercontent.com/gorhill/uBlock/master/src/js/redirect-resources.js') -> PyQt6.QtCore.QUrl('https://raw.githubusercontent.com/gorhill/uBlock/master/src/js/redirect-resources.js')
16:46:31 DEBUG    downloads  qtnetworkdownloads:_on_reply_finished:312 Reply finished, fileobj <_io.BytesIO object at 0x7fc0044a6b60>
16:46:31 DEBUG    downloads  qtnetworkdownloads:_finish_download:286 Finishing download...
16:46:31 DEBUG    downloads  downloads:_on_begin_remove_row:1063 _on_begin_remove_row with idx 0, webengine False
16:46:31 DEBUG    downloads  downloads:_remove_item:982 Removed download 1: redirect-resources.js [   0.00B/s|    ?|100%|5.25kB/5.25kB]
16:46:31 DEBUG    message    message:_log_stack:40 Stack for error message:
  File "/usr/bin/qutebrowser", line 33, in <module>
    sys.exit(load_entry_point('qutebrowser==3.0.0', 'gui_scripts', 'qutebrowser')())
  File "/usr/lib/python3.11/site-packages/qutebrowser/qutebrowser.py", line 231, in main
    return app.run(args)
  File "/usr/lib/python3.11/site-packages/qutebrowser/app.py", line 115, in run
    ret = qt_mainloop()
  File "/usr/lib/python3.11/site-packages/qutebrowser/app.py", line 125, in qt_mainloop
    return objects.qapp.exec()
  File "/usr/lib/python3.11/site-packages/qutebrowser/browser/qtnetworkdownloads.py", line 316, in _on_reply_finished
    self._finish_download()
  File "/usr/lib/python3.11/site-packages/qutebrowser/browser/qtnetworkdownloads.py", line 295, in _finish_download
    self.finished.emit()
  File "/usr/lib/python3.11/site-packages/qutebrowser/components/utils/blockutils.py", line 139, in _on_download_finished
    self.single_download_finished.emit(url, download.fileobj)
  File "/usr/lib/python3.11/site-packages/qutebrowser/components/ublock_resources.py", line 333, in _on_redirect_engine_download
    message.error("braveadblock: redirect-resources.js could not be parsed")
  File "/usr/lib/python3.11/site-packages/qutebrowser/utils/message.py", line 58, in error
    stack = ''.join(traceback.format_stack())
16:46:31 ERROR    message    message:error:63 braveadblock: redirect-resources.js could not be parsed

Env

qutebrowser v3.0.0
Git commit: d15263294 on makepkg (2023-08-19 15:08:37 +0900)
Backend: QtWebEngine 6.5.2, based on Chromium 108.0.5359.220 (from api)
Qt: 6.5.2

CPython: 3.11.5
PyQt: 6.5.2

Qt wrapper: PyQt6 (via QUTE_QT_WRAPPER)

colorama: no
jinja2: 3.1.2
pygments: 2.16.1
yaml: 6.0.1
adblock: 0.6.0
objc: no
PyQt6.QtWebEngineCore: 6.5.0
PyQt6.sip: 6.7.10
pdf.js: no
sqlite: 3.43.0
QtNetwork SSL: OpenSSL 3.1.2 1 Aug 2023

Style: QStyleSheetStyle
Platform plugin: xcb
OpenGL: NVIDIA Corporation, 4.6.0 NVIDIA 535.104.05
Platform: Linux-6.4.12-zen1-1-zen-x86_64-with-glibc2.38, 64bit
Linux distribution: Artix Linux (arch)
Frozen: False
Imported from /usr/lib/python3.11/site-packages/qutebrowser
Using Python from /usr/bin/python3
Qt library executable path: /usr/lib/qt6, data path: /usr/share/qt6

Now properly removes trailing block comments which appear in the upstream
redirect-resources.js. Also handles the inclusion of css resources.
@alkim0
Copy link
Author

alkim0 commented Sep 6, 2023

@duarm Thanks for letting me know. I've fixed the parse error.

However, since I've last touched the code, gorhill/uBlock@18a84d2 was committed, which completely refactors how scriptlets are written. This means scriptlet injection is basically broken for the moment until I write the code to parse the new format, and that may take some time.

To others, on a more practical level, this means you'll likely get ads on youtube and whatnot if you run :adblock-update-resources/:adblock-update, so I recommend not doing that for the moment.

@alkim0
Copy link
Author

alkim0 commented Sep 6, 2023

@The-Compiler @toofar I'd like some input.

To get scriptlet injection working, we need to parse the new scriptlets.js format. However, it's become more complex, and the recommended way now is to treat the file as a ES module to be imported in; in other words, use javascript to evaluate the file and produce the scriptlets from there (this is what Brave's adblocker does as well. See brave/adblock-rust@891a143).

So, we have the following options:

  1. Try to somehow continue with line-parsing.
  2. Import a javascript engine and parse scriptlets.js with it.

The downside with 1 is that it's brittle and less future-proof. The downside with 2 is that it requires interacting with a javascript engine (such as QJSEngine) from a component, which means we need to expose that via some API as well, making the PR process more complicated.

Which would you prefer?

@toofar
Copy link
Member

toofar commented Sep 6, 2023

There's a lot I don't understand, so I'll ask some questions that might seem silly because I don't have the time to do my own research.

  1. Are we not injecting these scriptlets into a JS environment anyway? Can we just inject the whole upstream file as-is and call the relevant functions as required? Or is there some indirection I'm missing? Do we have to feed the adblock module the individual scriptlets and let it decide what to inject instead?
  2. This file in adblock-rust says it parses the files from the repo, is it out of date? https://github.com/brave/adblock-rust/blob/master/src/resources/resource_assembler.rs
  3. Otherwise, we can probably just create a new tab and run the JS in there if we need to. Not sure what the UX would be. Could we do it in the background? Could we put some text on the page saying "ignore me lol"?

@alkim0
Copy link
Author

alkim0 commented Sep 7, 2023

Let me give a bit more context. To function correctly, the adblock engine must first be loaded with 1) filter lists 2) scriptlets (among other things) before visiting any websites. Then once a website is visited, the adblock engine uses the filter lists to determine which scriplets to inject. For example, this is a sample rule for scriplet injection:

youtube.com,youtubekids.com,youtube-nocookie.com#@#+js(rpnt, script, (function serverContract(), 'const pruner=text=>{const json=JSON.parse(text);for(k of["playerAds","adPlacements","adSlots"]){json[k]=[];}return JSON.stringify(json);};const urlFromArg=arg=>{if(typeof arg==="string"){return arg;}if(arg instanceof Request){return arg.url;}return String(arg);};const realFetch=window.fetch;window.fetch=new Proxy(window.fetch,{apply:function(target,thisArg,args){if(!(urlFromArg(args[0]).includes("player?key="))){return Reflect.apply(target,thisArg,args);}return realFetch(...args).then(realResponse=>realResponse.text().then(text=>new Response(pruner(text),{status:realResponse.status,statusText:realResponse.statusText,headers:realResponse.headers,})));}}); (function serverContract()')

This says when youtube.com is visited, run the rpnt scriplet with the arguments that follow. If the rpnt script is not loaded, then this rule does nothing.

Thus, code relating to the adblock engine can be divided into two phases.

  1. The setup phase, in which we load the scriplets. This is done by downloading scriplets.js, parsing it, and loading the adlbock engine with the parsed scriptlets.
  2. The actual website visiting phase, during which scriplet injection is performed.

The question I had asked was in reference to the setup phase (1).

To answer your questions:

  1. Are we not injecting these scriptlets into a JS environment anyway? Can we just inject the whole upstream file as-is and call the relevant functions as required? Or is there some indirection I'm missing? Do we have to feed the adblock module the individual scriptlets and let it decide what to inject instead?

We cannot inject the whole upstream file as-is, since the filter lists contain the exact details of how the scriplets should be injected. As you deduced, we have to feed the adblock engine with the scriplets, and it determines (based on the loaded filter rules) exactly how to inject the scriptlets.

  1. This file in adblock-rust says it parses the files from the repo, is it out of date? https://github.com/brave/adblock-rust/blob/master/src/resources/resource_assembler.rs

Yes. If you take a closer look, the [deprecated] annotation has been added along with the following comment:

The newer format is intended to be imported as an ES module, making line-based parsing even more complex and error-prone. Instead, it's recommended to transform them into [Resource]s using JS code.

This is why I suggested using a javascript engine.

  1. Otherwise, we can probably just create a new tab and run the JS in there if we need to. Not sure what the UX would be. Could we do it in the background? Could we put some text on the page saying "ignore me lol"?

This seems like it would be confusing from a UI perspective. Based on the documention, Qt's QJSEngine object seems like it would provide a javascript environment that is not bound to any browser tab instance, making it ideal for evaluating the scriptlets.js file. The reason I reached out was because I was wondering whether there were any plans for the API to expose such a javascript engine to components. If so, I'll gladly make use of that and do things the "right" way. Otherwise, I'll probably just end up hacking up a line-based parser of scriptlets.js.

EDIT: Also, thanks for the fast response!

@obsoleszenz
Copy link

Let me give a bit more context. To function correctly, the adblock engine must first be loaded with 1) filter lists 2) scriptlets (among other things) before visiting any websites. Then once a website is visited, the adblock engine uses the filter lists to determine which scriplets to inject. For example, this is a sample rule for scriplet injection:

youtube.com,youtubekids.com,youtube-nocookie.com#@#+js(rpnt, script, (function serverContract(), 'const pruner=text=>{const json=JSON.parse(text);for(k of["playerAds","adPlacements","adSlots"]){json[k]=[];}return JSON.stringify(json);};const urlFromArg=arg=>{if(typeof arg==="string"){return arg;}if(arg instanceof Request){return arg.url;}return String(arg);};const realFetch=window.fetch;window.fetch=new Proxy(window.fetch,{apply:function(target,thisArg,args){if(!(urlFromArg(args[0]).includes("player?key="))){return Reflect.apply(target,thisArg,args);}return realFetch(...args).then(realResponse=>realResponse.text().then(text=>new Response(pruner(text),{status:realResponse.status,statusText:realResponse.statusText,headers:realResponse.headers,})));}}); (function serverContract()')

This says when youtube.com is visited, run the rpnt scriplet with the arguments that follow. If the rpnt script is not loaded, then this rule does nothing.

Thus, code relating to the adblock engine can be divided into two phases.

  1. The setup phase, in which we load the scriplets. This is done by downloading scriplets.js, parsing it, and loading the adlbock engine with the parsed scriptlets.
  2. The actual website visiting phase, during which scriplet injection is performed.

The question I had asked was in reference to the setup phase (1).

To answer your questions:

  1. Are we not injecting these scriptlets into a JS environment anyway? Can we just inject the whole upstream file as-is and call the relevant functions as required? Or is there some indirection I'm missing? Do we have to feed the adblock module the individual scriptlets and let it decide what to inject instead?

We cannot inject the whole upstream file as-is, since the filter lists contain the exact details of how the scriplets should be injected. As you deduced, we have to feed the adblock engine with the scriplets, and it determines (based on the loaded filter rules) exactly how to inject the scriptlets.

  1. This file in adblock-rust says it parses the files from the repo, is it out of date? https://github.com/brave/adblock-rust/blob/master/src/resources/resource_assembler.rs

Yes. If you take a closer look, the [deprecated] annotation has been added along with the following comment:

The newer format is intended to be imported as an ES module, making line-based parsing even more complex and error-prone. Instead, it's recommended to transform them into [Resource]s using JS code.

This is why I suggested using a javascript engine.

  1. Otherwise, we can probably just create a new tab and run the JS in there if we need to. Not sure what the UX would be. Could we do it in the background? Could we put some text on the page saying "ignore me lol"?

This seems like it would be confusing from a UI perspective. Based on the documention, Qt's QJSEngine object seems like it would provide a javascript environment that is not bound to any browser tab instance, making it ideal for evaluating the scriptlets.js file. The reason I reached out was because I was wondering whether there were any plans for the API to expose such a javascript engine to components. If so, I'll gladly make use of that and do things the "right" way. Otherwise, I'll probably just end up hacking up a line-based parser of scriptlets.js.

EDIT: Also, thanks for the fast response!

So if I understand correctly, the scriptlet.js format changed which is basically the "db" for the scriptlets that are getting injected into the browser engine on the page load. And that changed from being a somehow parseable file to a es6/js file? Just throwing in an idea, would it be possible to write a js script, that loads that thing, iterates over all the urls and spits them out in a more sane format that is parseable again?

@alkim0
Copy link
Author

alkim0 commented Sep 14, 2023

So if I understand correctly, the scriptlet.js format changed which is basically the "db" for the scriptlets that are getting injected into the browser engine on the page load. And that changed from being a somehow parseable file to a es6/js file?

Yes

Just throwing in an idea, would it be possible to write a js script, that loads that thing, iterates over all the urls and spits them out in a more sane format that is parseable again?

That's what option 2 from #7629 (comment) would do and is the "recommended" way to do things according to the adblock-rust developers. However, something needs to execute the js script, which is where the talk of the javascript engine comes into play.

@The-Compiler
Copy link
Member

I'm currently a bit under the water with other stuff (semester starting and me moving), so just a couple of quick comments:

  • We already use QJSEngine for .pac files (though for QtWebKit only), in qutebrowser/browser/network/pac.py - it seems to have worked quite well so far.
  • I don't see a problem with importing Qt things in components freely. In fact, braveadblock.py already does from qutebrowser.qt.core import QUrl. I don't see why it can't do from qutebrowser.qt.qml import QJSEngine as well.
  • We have a couple of helpers like _js_slot in qutebrowser/browser/network/pac.py. If needed, they could be extracted to some utils module and used from components too. Right now, I don't really see components as a hard boundary. Unfortunately that work was interrupted in the middle of it, so at some point someone will need to sit down and clean things up (and design a nice API that makes sense) anyways.

@alkim0
Copy link
Author

alkim0 commented Sep 14, 2023

Great. Thanks for the pointers. I'll take a look at using QJSEngine and qutebrowser/browser/network/pac.py when I can.

Copy link

@port19x port19x left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took care of some linter ci failures for you, hope it helps

qutebrowser/components/ublock_resources.py Outdated Show resolved Hide resolved
qutebrowser/components/utils/exceptions.py Outdated Show resolved Hide resolved
tests/unit/components/test_ublock_resources.py Outdated Show resolved Hide resolved
qutebrowser/components/braveadblock.py Outdated Show resolved Hide resolved
Pull request backlog automation moved this from Inbox to WIP Sep 15, 2023
@alkim0
Copy link
Author

alkim0 commented Sep 19, 2023

I took care of some linter ci failures for you, hope it helps

Thanks. I appreciate it!

Co-authored-by: port19 <port19@port19.xyz>
Co-authored-by: port19 <port19@port19.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Development

Successfully merging this pull request may close these issues.

adblock: Support cosmetic filtering (element hiding) and scriptlets
6 participants