Coverage for src/local_deep_research/utilities/download_url.py: 91%
59 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""URL identity shared by download writers, readers and research deletion.
3No database, service or web imports are needed to load these helpers.
4"""
6import hashlib
7import re
9from loguru import logger
12def normalize_url_for_hash(url: str) -> str:
13 """Canonical normalization for URL-identity hashing (issue #6758 B2).
15 Composes the shared :func:`canonical_url_key` (strips tracking query
16 params, fragments, default ports, and userinfo; lowercases scheme and
17 host) with the historical scheme/``www``/trailing-slash stripping,
18 query-param sorting, and full lowercasing, so ``http`` vs ``https``,
19 ``www`` variants, tracking-param decor, and param order all hash alike.
21 Lowercasing happens BEFORE query-param sorting: ``sort(raw-case).lower()
22 != sort(lower(raw))`` whenever mixed-case query keys change relative
23 order (e.g. ``?q=x&Lang=fr&UserId=1``), and the tracker writer
24 re-normalizes an already-lowercased string — sorting after lowering on
25 one path but before lowering on the other yields different orders and
26 different hashes (issue #6760 follow-up). Sorting the lowered form is
27 deterministic on both paths and keeps the function idempotent.
29 The ``canonical_url_key`` import is lazy: this module is imported at
30 load time across both the database and research-library stacks. Defer
31 loading the utilities/security stack until normalization is needed.
32 """
33 from .url_utils import canonical_url_key
35 try:
36 normalized = canonical_url_key(url)
37 except Exception:
38 logger.warning("canonical_url_key failed, hashing raw URL")
39 normalized = url if isinstance(url, str) else ""
40 normalized = re.sub(r"^https?://", "", normalized or "")
41 normalized = re.sub(r"^www\.", "", normalized)
42 normalized = normalized.rstrip("/")
43 # Lowercase first so the sort below is on the lowered form (see
44 # docstring): otherwise mixed-case query keys sort differently before
45 # vs after lowering and a double-normalized writer diverges from a
46 # single-normalized reader.
47 normalized = normalized.lower()
48 if "?" in normalized:
49 base, query = normalized.split("?", 1)
50 params = sorted(query.split("&"))
51 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: URL query-param sorting only; this module builds no SQL
52 normalized = f"{base}?{'&'.join(params)}"
53 return normalized
56def get_url_hash(url: str) -> str:
57 """
58 Generate a SHA256 hash of a URL.
60 Args:
61 url: The URL to hash
63 Returns:
64 The SHA256 hash of the canonical normalized URL (issue #6758 B2)
65 """
66 return hashlib.sha256(normalize_url_for_hash(url).encode()).hexdigest()
69def _legacy_normalize_download_service(url: str) -> str:
70 """Pre-B2 (#6758) DownloadService._normalize_url spelling.
72 Stripped scheme/``www``/trailing-slash, sorted raw ``&`` query pairs,
73 lowercased — but did NOT strip tracking params, fragments, default
74 ports, or userinfo. Rows written before B2 still carry this hash.
75 """
76 if not isinstance(url, str) or not url: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 return ""
78 normalized = re.sub(r"^https?://", "", url)
79 normalized = re.sub(r"^www\.", "", normalized)
80 normalized = normalized.rstrip("/")
81 if "?" in normalized:
82 base, query = normalized.split("?", 1)
83 params = sorted(query.split("&"))
84 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: URL query-param sorting only; this module builds no SQL
85 normalized = f"{base}?{'&'.join(params)}"
86 return normalized.lower()
89def _legacy_normalize_library_service(url: str) -> str:
90 """Pre-B2 (#6758) LibraryService._get_url_hash spelling.
92 Same stripping as the download-service variant but WITHOUT query-param
93 sorting. A separate candidate is required for multi-param URLs whose
94 order differed.
95 """
96 if not isinstance(url, str) or not url: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 return ""
98 normalized = re.sub(r"^https?://", "", url)
99 normalized = re.sub(r"^www\.", "", normalized)
100 normalized = normalized.rstrip("/")
101 return normalized.lower()
104def get_legacy_url_hashes(url: str) -> list[str]:
105 """Return pre-B2 (#6758) url_hash candidates for ``url``.
107 Three legacy spellings existed in production:
108 - raw ``sha256(url.lower())`` (cascade_helper looked up trackers with
109 a bare ``get_url_hash`` that performed no stripping);
110 - download-service normalized (scheme/``www``/trailing-slash strip +
111 query sort);
112 - library-service normalized (same strip, no sort).
114 Used only as a read-compat fallback — new rows are always written with
115 the canonical :func:`get_url_hash`. Callers must try the canonical hash
116 first and fall back to these, never write them.
117 """
118 if not isinstance(url, str) or not url: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 return []
120 candidates: list[str] = []
121 raw_hash = hashlib.sha256(url.lower().encode()).hexdigest()
122 candidates.append(raw_hash)
123 dl_hash = hashlib.sha256(
124 _legacy_normalize_download_service(url).encode()
125 ).hexdigest()
126 if dl_hash not in candidates: 126 ↛ 128line 126 didn't jump to line 128 because the condition on line 126 was always true
127 candidates.append(dl_hash)
128 lib_hash = hashlib.sha256(
129 _legacy_normalize_library_service(url).encode()
130 ).hexdigest()
131 if lib_hash not in candidates:
132 candidates.append(lib_hash)
133 return candidates
136def get_url_hash_candidates(url: str) -> list[str]:
137 """Return ``[canonical, *legacy]`` url_hash candidates for ``url``.
139 B2 (#6758) re-keys every URL that ``canonical_url_key`` mutates
140 (tracking params, fragments, default ports, userinfo). Pre-upgrade
141 ``download_tracker`` rows still carry a legacy hash, so a lookup by
142 the canonical hash alone misses them — which previously drove
143 duplicate tracker rows and, worse, ``sync_library_with_filesystem``'s
144 "No tracker or path" branch deleting the document. Query
145 ``DownloadTracker.url_hash.in_(candidates)`` (canonical first) to stay
146 compatible with mixed-scheme tables without a data migration.
147 """
148 canonical = get_url_hash(url)
149 out = [canonical]
150 for legacy in get_legacy_url_hashes(url):
151 if legacy != canonical and legacy not in out:
152 out.append(legacy)
153 return out