Skip to content

Commit fc300f6

Browse files
committed
Strip tracking parameters from urls and html content
1 parent 96a534d commit fc300f6

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

app/models/media_item.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class MediaItem < ApplicationRecord
1212
before_save :widen_substack_email
1313
before_save :widen_bloomberg_email
1414
before_save :embed_images_and_resolve_links
15+
before_save :strip_tracking_params
1516
# after_save :cache_article
1617

1718
TEMPORARY_URL = "https://temporary.local".freeze
@@ -29,6 +30,22 @@ class MediaItem < ApplicationRecord
2930
MISSING_DURATION_THRESHOLD = 5
3031
EMAIL_WIDE_LAYOUT_PX = 950
3132

33+
TRACKING_PARAM_PATTERNS = [
34+
/\Autm_/i, # Google Analytics — utm_source, utm_medium, utm_campaign, ...
35+
/\Asi\z/i, # YouTube share id
36+
/\Afbclid\z/i, # Facebook click id
37+
/\Agclid\z/i, # Google ads click id
38+
/\Agbraid\z/i, # Google ads (iOS app)
39+
/\Awbraid\z/i, # Google ads (web)
40+
/\Amc_(cid|eid)\z/i, # Mailchimp
41+
/\A_hsenc\z/i, # HubSpot
42+
/\A_hsmi\z/i, # HubSpot
43+
/\AhsCtaTracking\z/i, # HubSpot
44+
/\Aigshid\z/i, # Instagram share id
45+
/\Ayclid\z/i, # Yandex
46+
/\Amsclkid\z/i, # Microsoft ads
47+
].freeze
48+
3249
scope :articles, -> { where(mime_type: HTML_MIME_TYPE) }
3350
scope :unarchived, -> { where(archived: false) }
3451
scope :reading_list, -> { articles.unarchived.joins(:feed, :libraries).order("feeds.priority ASC, media_items.created_at DESC") }
@@ -152,6 +169,35 @@ def widen_bloomberg_email
152169
self.description = doc.to_html
153170
end
154171

172+
def self.strip_tracking_params(url)
173+
return url if url.blank?
174+
uri = URI.parse(url)
175+
return url if uri.query.blank?
176+
params = URI.decode_www_form(uri.query).reject do |key, _|
177+
TRACKING_PARAM_PATTERNS.any? { |p| key.match?(p) }
178+
end
179+
uri.query = params.empty? ? nil : URI.encode_www_form(params)
180+
uri.to_s
181+
rescue URI::InvalidURIError
182+
url
183+
end
184+
185+
def strip_tracking_params
186+
self.url = self.class.strip_tracking_params(url)
187+
return unless html?
188+
189+
doc = Nokogiri::HTML(description)
190+
changed = false
191+
doc.css('a[href]').each do |link|
192+
cleaned = self.class.strip_tracking_params(link['href'])
193+
if cleaned != link['href']
194+
link['href'] = cleaned
195+
changed = true
196+
end
197+
end
198+
self.description = doc.to_html if changed
199+
end
200+
155201
def embed_images_and_resolve_links
156202
return unless html?
157203

test/models/media_item_test.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,78 @@ def create_stubbed_video(overrides)
300300
assert_not article.video?
301301
assert_not article.video_cached?
302302
end
303+
304+
test "strip_tracking_params removes utm_* keys" do
305+
url = "https://example.com/page?utm_source=x&utm_medium=y&utm_campaign=z&kept=1"
306+
assert_equal "https://example.com/page?kept=1", MediaItem.strip_tracking_params(url)
307+
end
308+
309+
test "strip_tracking_params removes youtube si parameter" do
310+
url = "https://www.youtube.com/watch?v=abc123&si=AbCdEf"
311+
assert_equal "https://www.youtube.com/watch?v=abc123", MediaItem.strip_tracking_params(url)
312+
end
313+
314+
test "strip_tracking_params removes other known trackers" do
315+
%w[fbclid gclid gbraid wbraid mc_cid mc_eid _hsenc _hsmi hsCtaTracking igshid yclid msclkid].each do |key|
316+
url = "https://example.com/page?#{key}=value&kept=1"
317+
assert_equal "https://example.com/page?kept=1", MediaItem.strip_tracking_params(url),
318+
"expected #{key} to be stripped"
319+
end
320+
end
321+
322+
test "strip_tracking_params preserves non-tracking parameters" do
323+
url = "https://example.com/page?id=42&q=hello"
324+
assert_equal "https://example.com/page?id=42&q=hello", MediaItem.strip_tracking_params(url)
325+
end
326+
327+
test "strip_tracking_params returns url unchanged when query is empty" do
328+
url = "https://example.com/page"
329+
assert_equal "https://example.com/page", MediaItem.strip_tracking_params(url)
330+
end
331+
332+
test "strip_tracking_params drops the query string when all params are trackers" do
333+
url = "https://example.com/page?utm_source=a&fbclid=b"
334+
assert_equal "https://example.com/page", MediaItem.strip_tracking_params(url)
335+
end
336+
337+
test "strip_tracking_params preserves fragment after stripping all params" do
338+
url = "https://example.com/page?utm_source=a#section"
339+
assert_equal "https://example.com/page#section", MediaItem.strip_tracking_params(url)
340+
end
341+
342+
test "strip_tracking_params returns blank input unchanged" do
343+
assert_nil MediaItem.strip_tracking_params(nil)
344+
assert_equal "", MediaItem.strip_tracking_params("")
345+
end
346+
347+
test "strip_tracking_params returns invalid url unchanged" do
348+
bad = "http://exa mple.com/?utm_source=x"
349+
assert_equal bad, MediaItem.strip_tracking_params(bad)
350+
end
351+
352+
test "before_save strips tracking params from url" do
353+
feed = feeds(:one)
354+
mi = feed.media_items.create!(
355+
url: "https://example.com/post?utm_source=newsletter&id=42",
356+
guid: "https://example.com/post-#{SecureRandom.hex}",
357+
title: "T",
358+
description: "plain text",
359+
mime_type: MediaItem::HTML_MIME_TYPE
360+
)
361+
assert_equal "https://example.com/post?id=42", mi.url
362+
end
363+
364+
test "before_save strips tracking params from anchor hrefs in description" do
365+
feed = feeds(:one)
366+
description = '<html><body><a href="https://www.youtube.com/watch?v=abc&si=tracker">v</a></body></html>'
367+
mi = feed.media_items.create!(
368+
url: "https://example.com/post",
369+
guid: "https://example.com/post-anchor-#{SecureRandom.hex}",
370+
title: "T",
371+
description: description,
372+
mime_type: MediaItem::HTML_MIME_TYPE
373+
)
374+
assert_includes mi.description, 'href="https://www.youtube.com/watch?v=abc"'
375+
assert_not_includes mi.description, "si=tracker"
376+
end
303377
end

0 commit comments

Comments
 (0)