| 1 | import type { SearchSource } from "./searchSources"; |
| 2 | import { isSafeHttpUrl } from "./searchSources"; |
| 3 | |
| 4 | export type SearchSourceView = { |
| 5 | title: string; |
| 6 | href: string; |
| 7 | hostname: string; |
| 8 | displayUrl: string; |
| 9 | canonicalUrl: string; |
| 10 | }; |
| 11 | |
| 12 | export type SearchSourcePresentation = { |
| 13 | visible: SearchSourceView[]; |
| 14 | hiddenCount: number; |
| 15 | }; |
| 16 | |
| 17 | const REDIRECT_HOST = /^(?:page\.sm\.cn|(?:www\.)?(?:google|bing)\.com)$/; |
| 18 | const REDIRECT_PATH = /^\/(?:url\/?|ck\/a)$/; |
| 19 | const REDIRECT_PARAMS = ["url", "u", "q", "target", "dest", "destination", "redirect", "redirect_url"] as const; |
| 20 | const TRACKING_PARAMS = new Set(["fbclid", "gclid", "dclid", "gbraid", "wbraid", "msclkid", "mc_cid", "mc_eid", "igshid"]); |
| 21 | |
| 22 | function isRedirectWrapper(parsed: URL): boolean { |
| 23 | const host = parsed.hostname.toLowerCase(); |
| 24 | return host === "page.sm.cn" || (REDIRECT_HOST.test(host) && REDIRECT_PATH.test(parsed.pathname.toLowerCase())); |
| 25 | } |
| 26 | |
| 27 | function unwrapRedirectUrl(parsed: URL): URL | null { |
| 28 | if (!isRedirectWrapper(parsed)) return parsed; |
| 29 | for (const key of REDIRECT_PARAMS) { |
| 30 | const candidate = parsed.searchParams.get(key); |
| 31 | if (!candidate || !isSafeHttpUrl(candidate)) continue; |
| 32 | try { return new URL(candidate); } catch { /* keep looking */ } |
| 33 | } |
| 34 | return null; |
| 35 | } |
| 36 | |
| 37 | function canonicalizeUrl(raw: string): URL | null { |
| 38 | try { |
| 39 | const parsed = new URL(raw); |
| 40 | if (!isSafeHttpUrl(raw)) return null; |
| 41 | const unwrapped = unwrapRedirectUrl(parsed); |
| 42 | if (!unwrapped) return null; |
| 43 | for (const key of [...unwrapped.searchParams.keys()]) { |
| 44 | if (key.toLowerCase().startsWith("utm_") || TRACKING_PARAMS.has(key.toLowerCase())) unwrapped.searchParams.delete(key); |
| 45 | } |
| 46 | unwrapped.hash = ""; |
| 47 | return unwrapped; |
| 48 | } catch { return null; } |
| 49 | } |
| 50 | |
| 51 | function shortDisplayUrl(parsed: URL): string { |
| 52 | const host = parsed.hostname.replace(/^www\./i, ""); |
| 53 | const path = `${parsed.pathname}${parsed.search}`; |
| 54 | const display = `${host}${path === "/" ? "" : path}`; |
| 55 | return display.length <= 92 ? display : `${display.slice(0, 91)}…`; |
| 56 | } |
| 57 | |
| 58 | /** Build a safe, compact display projection without changing replay data. */ |
| 59 | export function normalizeSearchSources(sources: SearchSource[] | undefined): SearchSourcePresentation { |
| 60 | const visible: SearchSourceView[] = []; |
| 61 | const seen = new Set<string>(); |
| 62 | let hiddenCount = 0; |
| 63 | for (const source of sources ?? []) { |
| 64 | const title = (source.title ?? "").trim(); |
| 65 | const parsed = canonicalizeUrl((source.url ?? "").trim()); |
| 66 | if (!title || !parsed) { hiddenCount += 1; continue; } |
| 67 | const canonicalUrl = parsed.toString(); |
| 68 | if (seen.has(canonicalUrl)) { hiddenCount += 1; continue; } |
| 69 | seen.add(canonicalUrl); |
| 70 | visible.push({ title, href: canonicalUrl, hostname: parsed.hostname.replace(/^www\./i, ""), displayUrl: shortDisplayUrl(parsed), canonicalUrl }); |
| 71 | } |
| 72 | return { visible, hiddenCount }; |
| 73 | } |
| 74 |