You click a link to a local paper and the headline loads, then the picture, then the first three paragraphs, and you have started reading before anything else happens. Then the page goes blank. Or it turns into a heap of unstyled links with the logo stretched across the full width of the screen. Or a box slides up asking you to either accept tracking or pay £2.99 a month, with no third button.

The article was on your machine. The publisher’s server had already sent all of it, text and stylesheets, and your browser had already drawn it. What took it away was code the publisher chose to run afterwards, on your computer, bought from a commercial vendor whose product is taking back what was just delivered.

I run a Pi-hole1 at home, which blocks advertising and tracking hosts for every device on the network. On a growing list of UK news sites that was enough to get the page destroyed. So on 22nd September 2026 I built a Chrome extension to stop it, called Keep The Page. The code is on GitHub at damo2929/browserplugin, MIT licensed, and this is what it does, what I found inside those pages while building it, and the fixes that made things worse before the right one turned up.

It is not a paywall bypass. If the server never sent the article, nowt here will conjure it up. The Times stayed shut and that is correct. What it keeps is what you were already sent.

The Article Arrives, Then It Goes

Every one of these works the same way from the outside. The HTML arrives complete, and then a script, usually loaded from a host belonging to a vendor rather than the paper, decides you are not worth showing it to, and removes it.

What it removes, and how, depends on which vendor the publisher bought. These are the four I met, each read off a live page during the build:

PublisherWhat arrivesWhat the page then does to itself
Newsquest (269 titles)2the full articlebuilds a wall, runs an eval payload, raises a confirm() dialog
notebookcheck.netthe full articledeletes <body> about 7 seconds in, then dialogs, then a reload loop
National World (The Scotsman, Yorkshire Post)3the article plus about 68KB of its own CSSdeletes every <link> and <style> every 100ms, forever
Reach plc (Mirror, Daily Record, Manchester Evening News, Liverpool Echo and others)4the full articlecovers it with “accept tracking or pay £2.99/month”

The 269 is not from a press release, which says “more than 200 brands”. It is the count of domains on Newsquest’s own TLS certificates, the list the extension needed to know where to run.

The National World one is the one that should worry publishers most, because it breaks the page for reasons that have nothing to do with advertising. The stylesheet stripper has a partner that fetches the CSS back from the vendor’s host. My Pi-hole blocks that host. So the strip ran and the restore never came, and the paper’s own layout was left permanently dependent on an ad vendor being reachable. That is not a wall. It is a publisher handing the look of its own website to a third party and not noticing.

Can I ask why a newspaper’s stylesheet has to wait for an advertising company’s server before it is allowed to stay on the page? There is no reader-facing reason for it. None.

Why I Call It Malware

I use the word on purpose, as a description of what the code does, and not as a legal finding about anybody. The anti-adblock payloads meet the ordinary meaning on four counts, and every one was observed directly:

TestWhat I saw
Runs without consent, against your interestnobody asked for it, and its job is to take away content you already have
Destroys data already delivered to youthe article and its CSS arrive intact, then in-page code deletes them
Obfuscated to resist readingpermuted string tables like o[293 * (r + 450) % e], executed through eval
Evades blocking and punishes interferenceCNAME cloaking to dodge DNS blocklists, anti-tamper checks that escalate to a dialog or a reload loop

The obfuscation is not minification. Minified code is small. This is code arranged so that you cannot grep it for what it does, and the payload is then run through eval so nothing on disk matches what executes.

The cloaking is a known technique with its own research literature5. The loader is fetched from what looks like a subdomain of the newspaper, and that name resolves to the vendor:

a02342.<publisher-domain>   ->  cdn-52-x.privacy-mgmt.com      (Sourcepoint)
fb.html-load.com            ->  adshield-fallback-dev-wskxz.b-cdn.net

The subdomain is randomised per title. As such a blocklist that names hosts cannot keep up, which is the point of doing it.

And the code treats being interfered with as proof of guilt. The decoded error strings in the payload that filter-list maintainers attribute to Ad-Shield6 are literally Vital API blocked and Vital API blocked (eval). Sourcepoint’s loader writes an attribute, reads it straight back, and throws if the value has changed:

z.call(O,'src',G), O[x]('src') !== G && throw E
… catch (W) { try { await l(W) } catch (x) { o(W) } }   // o() raises the dialog

Sourcepoint have sold this openly. Their own documentation said “on average about 30% of messaged users will turn off their adblockers”7. So I am not describing a rogue script somebody slipped in. It is a product, bought and deployed on purpose by the publisher.

The consent-or-pay walls are a different category and I do not call them malware. They do not destroy what was delivered and they do not hide from blocklists. They are coercive in a different way, which is the next section.

Accept 1,467 Partners Or Pay £2.99

The wall on Reach titles is Quantcast Choice, now run by InMobi8 and served from cmp.inmobi.com. It gives two choices. Accept, or pay £2.99 a month. There is no free “no”.

Accepting shares your data with 1,467 listed partners and writes a euconsent-v2 cookie that lasts 13 months. Nobody reads a list of 1,467 companies, nobody could weigh what each of them would do with the data if they did, and the number on its own tells you what kind of consent is being asked for.

UK GDPR says consent has to be freely given, and that when you judge whether it was, you look at whether the service was made conditional on consent to processing it does not need9. The ICO has published guidance saying consent or pay can be lawful10, guidance it now says is under review, and the European Data Protection Board has said that for large platforms offering only the two options, in most cases it will not be11. I will not pretend the regulator has banned it. It has not.

So here is where I land, plainly. I chose to remove the wall and to refuse the consent. That means I read the article on the free branch without paying and without handing my data to 1,467 companies. It is a decision. The extension says so in its own settings page, and I am not dressing it up as something neutral. A consent you cannot refuse is a price, and I will not pay a price that is dressed up as a question.

Removing the box is the easy third of it. The other two thirds are in Answering The Consent Question Properly, because a banner you delete without answering comes back every time.

One Day, Eight Commits

The whole thing was built on one day. There were a few hours of poking at pages before anything was committed, then eight commits between 20:32 and 22:50. I built it with Claude Code, and a lot of the graft of reading obfuscated inline script line by line was done by the agent while I watched what the pages did on screen. That split worked well, and where it went wrong is below, because it went wrong in a way worth knowing about.

TimeCommit
20:32first commit: Newsquest, notebookcheck, National World, Reach, Page Six
20:46the vendor behind each mechanism, written into the README
20:5650 links off the Google News front page, as an unchosen sample
21:02answer the consent API with every purpose denied
21:11social links defused
22:50switchable protections, MSN and Bing, consent refusal by click, 77 tests

It is a Manifest V3 extension with two permissions, declarativeNetRequest and storage, and it has no host permissions and no network access of its own, so it cannot fetch anything, rewrite a response or talk to a server on your behalf. That constraint shaped the design more than anything else, because the interesting work has to happen inside the page.

Two worlds, one attribute down and one event upThe page's code and the extension's settings live in different worldsExtension storagechrome.storage.localprotectionstracing channelssocial settingslast 50 errorsread and written bythe options pageIsolated worldbridge.jssocial.jsportal.jscan read chrome.storagecannot touch the page'sown functionsPage world (MAIN)walls.jsguard.jsportal-early.jswraps setTimeout, cookie,__tcfapi, window.adLightno chrome.* at allDown: an attribute on the html element, only what is off<html data-ktp-off="cookies,dom">default install: nothing writtenUp: a ktp-report eventdetail: a JSON stringan object does not cross reliably
Chrome runs extension scripts in two worlds. The page’s world can reach the page’s own JavaScript but has no extension APIs. The isolated world can read settings but cannot touch the page’s functions. Everything crosses between them as an attribute going down and an event coming up.

Chrome’s MAIN world shares the page’s own JavaScript environment12. A script there can replace setTimeout, wrap document.cookie or define window.adLight before the page does, which is exactly what fighting these walls needs. In practice it cannot call chrome.storage. The isolated world can, but cannot see the page’s functions. So a small bridge reads the settings and writes them onto <html>, and errors come back up as a CustomEvent whose detail is a JSON string, because an object does not cross that boundary reliably.

The attribute going down lists only what you have switched off. A default install writes nothing to the page at all. That matters because a permanent marker on <html> is precisely the kind of thing these SDKs look for, and it means a storage failure fails towards defending the page rather than away from it.

Find The Gate, Do Not Fight The Wall

The Newsquest fix is two lines of reasoning and it is the one everything else was measured against.

Their whole wall sits behind one flag in the page:

var adLight = false;        // line 1647
if (adLight !== true) { …}  // line 2020: loader, eval payload, confirm()

adLight is the subscriber “light ads” flag. If it is true, the wall never builds. So the extension defines window.adLight as true at document_start, before the page’s own script runs, with a setter that ignores whatever is written to it:

Object.defineProperty(window, 'adLight', {
  configurable: false,
  enumerable: true,
  get() { return true; },
  set() { /* ignore the page's "false" */ }
});

A var at the top of a script does not redefine a property the global object already has. It only assigns to it13. So the page’s own var adLight = false runs, lands on the setter, and does nothing. The wall’s loader never starts, the eval payload never arrives and there is no anti-tamper check to trip, because nothing was tampered with. The flag just said yes.

That is the only property in the whole extension that is non-configurable. It has to be, to survive the declaration. Everything else is configurable: true, so no page ever has one of its own APIs seized for good.

It covers 269 titles and it cannot be switched off in the settings, which say why: it runs before chrome.storage can answer, and once set it cannot be undone. A checkbox would be decoration.

Every Fix That Made It Worse

This is the useful section, because every mistake here is the obvious thing to try.

What I triedWhat happened
Block the loader’s hostthe host is a random first-party CNAME per title; and a failed fetch is itself the detection signal
Guard setAttribute on injected scriptstripped Sourcepoint’s read-back check, which raised the dialog it was meant to stop
Answer the confirm() with Cancelin this SDK Cancel means reload, so it caused an infinite reload loop
Snapshot <body> at DOMContentLoaded to restore laterthe wall blanks during parsing, so the snapshot was of an empty body
Guard remove() and removeChild()the wall clears the page with one innerHTML = '', not node by node
Guard everything at once on notebookcheckwall escalated to a dialog, then a reload loop; strictly worse than doing nothing
Redirect the vendor host to a local stuba redirect rule with only declarativeNetRequest invalidated the whole ruleset, silently killing the Newsquest rule on 269 sites
Run the page guards on every sitepatched global prototypes on every page I visited, including my bank

The redirect one is worth a second look. Chrome gives a block rule implicit access and wants host permission for anything more14. Its documentation says invalid static rules are ignored15. What I saw was worse than that: the whole file went, with no error on the page, and rule 1 just stopped existing on 269 sites. The two rules for MSN now live in a ruleset of their own for that reason, so a bad edit to one cannot take the other down.

The reload loop taught the other hard rule. location.reload cannot be intercepted. The Location object is unforgeable in the HTML standard16, so its methods are non-writable and non-configurable, and trying gives you:

Object.defineProperty(location,'reload',...)
-> TypeError: Cannot redefine property: reload

Any wall whose failure path is “reload the page” cannot be stopped once it is on that path. Nothing can. The only fix is to make sure it never gets there. Which is the same lesson as adLight, learned the painful way: every attempt to fight a wall already running made things worse, and every fix that worked stopped the wall from starting.

One Timer Did The Damage

notebookcheck has no adLight. The wall always runs. With tracing on, the extension showed what it scheduled:

dropped setTimeout(7005ms) scheduled from eval    <- the body.remove()
dropped setTimeout(1251ms) / 105ms / 0ms x8
dropped setInterval(15000ms)

One of those timers does all the damage: the one at 7 seconds that removes <body>. Everything after it is a reaction, because the blank page throws, the exception raises the dialog, the dialog reloads the page and the whole thing starts over from the top with a fresh set of timers.

So the fix is one rule. Drop a timer if it was scheduled from eval’d code, the page carries this SDK’s signature and the handler is a function. The stack tells you where a call came from, and eval leaves its mark in it.

before: 4 page loads, 3 confirms, reload loop
after:  1 page load,  0 confirms, alive 40,378ms, content intact
One timer, and everything downstream of itnotebookcheck: one timer does the damage, the rest is reactionAs servedarticle and CSSarrive, drawnloader fromhtml-load.comeval payloadschedules timers7.0s: timer runsbody.remove()blank page throws,confirm() raisedpage reloadsand starts again4 page loads, 3 dialogs, reload loopWith the extensionarticle and CSSarrive, drawnloader fromhtml-load.comeval payloadschedules timersevery eval timerdropped at once1 load, 0 dialogs,alive at 40 seconds
notebookcheck without and with the extension. Nothing downstream of the 7-second timer needs fixing, because none of it happens once that timer is dropped.

There were two other mechanisms in the build at that point, a redirect of the loader to a stub and a decoy element to soak up the wall’s writes. Both worked, and both were treating symptoms of that one timer, which only became obvious when the timer rule was tested on its own and turned out to be enough by itself. So both came out, and taking them out dropped a permission and every host permission with them. Always test whether the last change alone does the job before you keep the scaffolding round it.

The signature is the data-sdk attribute on the loader tag, and it matches a shape, l/<n>.<n>, rather than a version. Three versions turned up in one day. It latches on and never caches a negative, because the loader tag may not be parsed yet when the first timers are scheduled, and a remembered “no” would disarm it for good on a page that does carry the wall.

The Probe Said Fine. The Screen Did Not.

The Scotsman and the Yorkshire Post were reported fixed off a probe that measured text. The page had 8,808 characters of it, 22 elements under <body>, steady at 2, 8 and 16 seconds. By that measure it was intact.

It was not. I was looking at it, and every stylesheet was gone, the links were a raw list, the SVG logo filled the screen and there was a horizontal scrollbar. All the words were present, which is all that probe could see. I had to point at the screen and say so.

What the probe measured, and what was on the screenYorkshire Post, before the fix: the same page, measured two waysWhat the probe measuredinnerText.length8,808body children22at 2s, 8s, 16sunchangedVerdict: intactWhat was on the screendocument.styleSheets.length0links as a raw list, logo full width,a horizontal scrollbarVerdict: brokenAfter the stripper was dropped at schedule time: 2 stylesheets, 532 rules, the page renders
The same page, measured two ways. Text length said the page was healthy. The stylesheet count said it was broken, and the stylesheet count was right.

The cause was the stripper from the first table, and it did not match the timer rule, because it is a plain inline script and not eval. So its own source is the signature: a repeating job whose body calls querySelectorAll('link,style') and then remove(). Nothing legitimate does that. It is dropped at schedule time, nothing is ever stripped and nothing needs restoring. Yorkshire Post went from 0 stylesheets to 2, with 532 rules, and it rendered.

Finding it needed a stack trace, not a guess. Patch Element.prototype.remove to log the stack whenever it removes a STYLE or LINK, and it named the inline script and the forEach on the first try.

After that, document.styleSheets.length went into every check. A page with none is broken however much text it has. Measuring gets harder than you would think, and these are the traps that caught the build on the day:

TrapWhat it saidWhat was true
text length as a render check“intact”unstyled markup
one sample after loadReach walls “not present” on eight titlesthe wall lives about 600ms and was already swept
empty console after navigating“the guard is not firing”load-time messages do not survive the navigation
sampling at 1s and 4snotebookcheck healthyit blanks at 5 to 8 seconds
cssRules counted across originsESPN had 5 rulescross-origin sheets throw, so they read low

The fix for the 600ms one is to poll every 100ms from the moment the page loads. On Wales Online the wall appeared at 425ms and was gone by 1,129ms.

Then the checks were run for real. 52 articles across 26 domains, two per site, chosen to cover every mechanism: 52 of 52 with stylesheets, no wall left on screen, no reload loops. Then 50 links straight off the Google News UK front page, not chosen by me: 45 rendered normally, 2 were hosts my Pi-hole blocks on purpose, 1 was The Times’s real paywall, and 2 were the same article landed twice because Google rebuilds the link order on every load. None with zero stylesheets. Three National World titles I had never tested turned up in that run carrying the same SDK, and all three rendered, which is what matching a shape instead of a site list is for.

There are 77 unit tests, in the repository with everything else. There is no Node on this machine, so they run on gjs, and they load the real walls.js against a stand-in DOM, so the production patterns are what is under test. Every one was checked by breaking the thing it guards and watching it go red. They test decisions only. Passing them does not mean a page renders, and the Scotsman is why that sentence is in the README.

Deleting a consent banner leaves the question unanswered. That has two consequences, and the first is that the banner is rebuilt on every page load. And a publisher that holds its content back until the consent API replies will simply hang, while a vendor that gets no reply can treat the question as never asked. Silence is not refusal.

So the extension answers it, in this order:

Refuse, answer no, store nothing, and only then removeA consent banner is answered, not just deletedA consent vendor's own container is on screen#qc-cmp2-container #onetrust-consent-sdk sp_message_container1. Press their refusalReject all, Decline, Only essential,Continue without acceptingwhole label only, 40 characters maxmatches Accept: the build fails2. Answer the API: no__tcfapievery purpose, feature andvendor deniedtcString "" tcloaded3. Never store the recordeuconsent-v2 addtl_consentOptanonConsent didomi_tokencookie and localStoragewrites droppedStill on screen at the next tick?yes: remove it, unlock scrollingno: the refusal stands
Three answers and a fallback. The refusal button is pressed if there is one, the consent API is told no to everything, the record is never stored, and only a banner still standing on the next tick is removed.

It presses their refusal button first. Only buttons whose whole label is a refusal: “Reject all”, “Decline”, “Only essential”, “Continue without accepting” and a few more, each anchored, anything over 40 characters ignored. A unit test feeds it “I Accept”, “Accept All”, “Agree and close”, “Allow all”, “Got it”, “Subscribe” and “Pay £2.99/mo” and fails the build if any of them match. Clicking the wrong button would consent on your behalf, which is the one thing this project must never do. On msn.com, pressing “Reject All” got rid of Microsoft’s banner and it did not come back on reload, because Microsoft keep the refusal on their own servers.

It answers the consent API with no. The IAB’s framework gives every consenting page a function called __tcfapi to ask what you agreed to17. Where one exists, the extension answers it with every purpose, every special feature and every vendor denied, an empty consent string and eventStatus: 'tcloaded', which means the answer is final. It only appears where a consent framework is already on the page, so a site that never asked does not see it.

It never stores the record. document.cookie and localStorage silently drop euconsent-v2, addtl_consent, OptanonConsent, didomi_token and the rest, so a consent you never gave is never written and never replayed to 1,467 partners on the next page. UK law already requires consent before anything is stored on your device for this18. The extension enforces the no.

Every name on that list is anchored at both ends, and there is a story behind it. Sourcepoint’s cookies start _sp_. A lazy pattern of sp_ also matches Spotify’s sp_dc and sp_t, which are the login session, and would have logged me out of Spotify on every page. A test pins that too.

Removal is the fallback. Only for a banner still on screen after the click, and only a banner that is actually showing. Several vendors leave a permanent wrapper in the page whether or not a banner is up. euronews keeps a Didomi host at zero height, and tearing that out breaks the page for no gain. The Reach one sat three levels below <body> inside two wrappers that are not themselves fixed, so the check has to walk down to the box that is.

Share Buttons, And A Portal That Ignores Its Own Settings

Two more things on these pages exist to serve somebody other than the reader, and they needed different handling from the walls.

Share and follow buttons. Every link to Facebook, Instagram, X, TikTok or LinkedIn is rewritten to http://localhost/removeme, with the original parked in an attribute so nothing is lost. Then a second pass deletes what is plainly furniture (an icon with no text, “Share on X”, anything in a container that calls itself share or social) and hides the rest. The marker is there so one selector shows everything about to go, and a mark-only mode stops after the first pass so you can look before trusting it on a site.

The naive version breaks pages three ways, and each is now a test:

Naive versionWhat it breaksWhat is done instead
a[href*="x.com"]also matches netflix.com, linux.com, phoenix.comparse the host and compare whole labels
remove every social link“Continue with Facebook” goes and people are locked outlogin, OAuth, legal and developer links left alone
remove a link in a sentencethe words go with ithide by default; an option unwraps it and keeps the words

That last one is a trade-off I made knowingly. A BBC sign-off reads “follow BBC Manchester on , , and .” with it on. I looked at that and chose hiding anyway. The option to keep the words is there for anyone who disagrees.

MSN and Bing. The same web components run both, around 160 shadow roots on the front page. A plain query of the document found 1 link. Walking the shadow roots found 73, including the Facebook and X tiles. Anything that does not walk them sees almost none of it.

MSN does have its own content settings. They are held on Microsoft’s servers against an anonymous ID, not in a cookie, so they are gone after a cookie clear, in a new profile and in incognito. They do not reach Bing at all. And with every one of them switched off and 11,700 pixels scrolled, this is what was still in the feed:

MSN’s own switch, offStill on the page
Casual Gamesthe Games tile
Shoppingadvertising tiles for Booking, Temu and eBay
Comments501 comment links
Weather, Finance, Sportsgone, until the next cookie clear

A switch labelled Comments that leaves 501 comment links on the page is a claim made to the user, and a false one. So the extension takes those out itself, targeting MSN’s stable component names rather than its class names, which change every build.

One thing I learned there that applies well beyond MSN: removing an image from the page does not stop it loading. Chrome starts the fetch when src is set, even for an image never put in the page19. By the time a content script sees a card its thumbnail is already on the wire. So the feed data is blocked at the two endpoints that serve it, and never the image hosts, because th.bing.com also serves Bing image search.

And the MSN feed still flashes on screen for a moment before it goes. Four attempts at hiding it earlier all measured as working and none of them stopped the flash, which means what paints is not what the extension hides. The settings page says so plainly. I would rather it said that than implied a clean load it does not deliver.

What It Will Not Do

It will notBecause
open a real paywallif the server withholds the article, it stays withheld
guess at a sitea domain goes in only after its wall is seen there; two News Corp sites assumed to match Page Six did not, and came out again
touch a site with no signatureon the BBC every hook is installed and nothing is logged, no timer dropped, no node touched
call homeno host permissions, no network access, no telemetry; the error log stays in your browser
pretendthe MSN flash is unsolved, and one Ad-Shield variant, wp-ls/…, does not match yet; its page rendered fine, so it is written down rather than fixed on a guess

A Page You Were Sent Is Yours

Once a server has sent your browser a page, that copy is on your machine. Running code on it afterwards to take it away is not a business model I recognise. It is the old trick of selling you something and then keeping a hand on it.

Newspapers used to be a thing you bought and then owned: somebody on the street corner had a stack, you handed over the money, and it went home with you and was yours to read, fold, lend or light the fire with. Nobody came round the house an hour later to cut the second page out because you had skipped the adverts. The web version gave the paper away for nowt and then sold the reader. First to the advertisers, then to the 1,467 partners, and now to a vendor whose whole product is deciding whether you have behaved well enough to keep what you were given.

The part I cannot get past is the stylesheet. A paper that lets an ad company’s server decide whether its own layout survives has given away its own front page. It will not have known it had, because nobody inside was blocking the host, so the first person to find out was a reader with a Pi-hole, looking at a page full of raw links and being told by a dialog box that the fault was theirs. It was not.

There is a price for journalism and I have no problem paying it. What I will not do is let a script decide what I have already got. That line gets drawn in my browser, not theirs.


  1. Pi-hole documentation — “The Pi-hole® is a DNS sinkhole that protects your devices from unwanted content, without installing any client-side software.” ↩︎

  2. Newsquest — About us — “We are the leading local news publisher in the UK with a portfolio of more than 200 brands.” Brands are not domains, hence the higher count off the certificates. ↩︎

  3. Daily Business, 18th December 2024 — “National World, owner of The Scotsman and Yorkshire Post, has reached agreement on a £65.1 million takeover by Irish publisher Media Concierge.” ↩︎

  4. Reach plc — About us, archived 5th September 2026 — “120+ brands, from household names like the Mirror, Express, Daily Record and Daily Star, to local titles like MyLondon, BelfastLive and the Manchester Evening News”. ↩︎

  5. Dimova et al., “The CNAME of the Game: Large-scale Analysis of DNS-based Tracking Evasion”, PETS 2021 — CNAME cloaking “effectively bypasses antitracking measures that rely on fixed hostname-based block lists.” ↩︎

  6. uBlockOrigin/uAssets issue #30988, filed under the maintainers’ “Ad-Shield” label, with the message served from error-report.com: “Failed to load website properly since html-load.com is blocked.” See also Jacob Desforges, “Ad-Shield ad reinsertion”, 12th April 2026. The attribution is the filter-list community’s; the vendor discloses nothing. ↩︎

  7. Sourcepoint — Anti-adblock FAQs, archived 25th May 2022 — “Historical experience shows on average about 30% of messaged users will turn off their adblockers.” The same page asks whether “a CNAME applied to a 1st-party subdomain” would stop the detection script being blocked. ↩︎

  8. AdExchanger, 16th August 2023 — “InMobi acquired Quantcast’s consent management platform, called Quantcast Choice”. ↩︎

  9. UK GDPR, Article 7 — “utmost account shall be taken of whether, inter alia, the performance of a contract, including the provision of a service, is conditional on consent”. Recital 42: consent is not freely given “if the data subject has no genuine or free choice”. ↩︎

  10. ICO — Consent or pay, published 23rd January 2025 — ““Consent or pay” models can be compliant with data protection law if you can demonstrate that people can freely give their consent”. The page now says the guidance is under review following the Data (Use and Access) Act. ↩︎

  11. EDPB Opinion 08/2024, adopted 17th April 2024 — “In most cases, it will not be possible for large online platforms to comply with the requirements for valid consent if they confront users only with a binary choice”. It addresses large online platforms, not regional papers. ↩︎

  12. Chrome for Developers — content_scripts manifest key — “Choosing the “MAIN” world means the script will share the execution environment with the host page’s JavaScript.” ↩︎

  13. ECMAScript — CreateGlobalVarBinding — “If a binding already exists, it is reused and assumed to be initialized.” ↩︎

  14. Chrome for Developers — declarativeNetRequest — “provides implicit access to allow , allowAllRequests and block rules”, and otherwise “you must request host permissions before you can perform any action on a host.” ↩︎

  15. Chrome for Developers — declarativeNetRequest — “Errors and warnings about invalid static rules are only displayed for unpacked extensions. Invalid static rules in packed extensions are ignored.” ↩︎

  16. HTML Standard — the Location interface marks its members [LegacyUnforgeable], which in Web IDL means “the property will be non-configurable and will exist as an own property on the object itself”. ↩︎

  17. IAB Tech Lab — TCF v2 CMP API — “Every consent manager MUST provide the following API function: __tcfapi(command, version, callback, parameter)”. ↩︎

  18. PECR, regulation 6 — “a person must not store information, or gain access to information stored, in the terminal equipment of a subscriber or user”, with consent as the condition in Schedule A1, as amended by the Data (Use and Access) Act 2025. ↩︎

  19. HTML Standard — update the image data — runs “whenever that element is created or has experienced relevant mutations”, including when its src is set; being in the document is not a condition. ↩︎