返回 DeepSeek-Reasonix
searchSourcesPresentation.ts
根目录 / desktop / frontend / src / lib / searchSourcesPresentation.ts
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
74 lines TYPESCRIPT