@yukioa2z/visa-apply 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env python3
2
+ """Monitor registered official visa sources for review-worthy content changes.
3
+
4
+ This script deliberately detects source-page changes, not legal conclusions. A
5
+ changed fingerprint means that a human should review the official page before
6
+ updating any visa rule or applicant guidance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import threading
16
+ import urllib.error
17
+ import urllib.request
18
+ from collections import defaultdict
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+ from datetime import datetime, timezone
21
+ from html.parser import HTMLParser
22
+ from pathlib import Path
23
+ from typing import Callable
24
+ from urllib.parse import urlparse
25
+
26
+
27
+ ROOT = Path(__file__).resolve().parents[1]
28
+ REGISTRY_PATH = ROOT / "references" / "official-sources.json"
29
+ DIRECTORY_PATH = ROOT / "references" / "jurisdictions.json"
30
+ DEFAULT_OUTPUT_PATH = ROOT / "references" / "policy-monitor.json"
31
+ MAX_RESPONSE_BYTES = 5 * 1024 * 1024
32
+ MIN_NORMALIZED_CHARACTERS = 80
33
+ MAX_CONNECTIONS_PER_HOST = 2
34
+ GUARDED_STATUSES = {401, 403, 405, 406, 429, 503}
35
+ BROWSER_USER_AGENT = (
36
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
37
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
38
+ )
39
+
40
+
41
+ class VisibleTextParser(HTMLParser):
42
+ """Extract stable visible text while ignoring executable/decorative markup."""
43
+
44
+ IGNORED_TAGS = {"script", "style", "noscript", "svg", "template"}
45
+
46
+ def __init__(self) -> None:
47
+ super().__init__(convert_charrefs=True)
48
+ self._ignored_depth = 0
49
+ self.parts: list[str] = []
50
+
51
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
52
+ del attrs
53
+ if tag.casefold() in self.IGNORED_TAGS:
54
+ self._ignored_depth += 1
55
+
56
+ def handle_endtag(self, tag: str) -> None:
57
+ if tag.casefold() in self.IGNORED_TAGS and self._ignored_depth:
58
+ self._ignored_depth -= 1
59
+
60
+ def handle_data(self, data: str) -> None:
61
+ if not self._ignored_depth:
62
+ self.parts.append(data)
63
+
64
+
65
+ def utc_timestamp() -> str:
66
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
67
+
68
+
69
+ def normalize_content(payload: bytes, content_type: str = "") -> str:
70
+ """Return whitespace-normalized text suitable for a content fingerprint."""
71
+
72
+ text = payload.decode("utf-8", errors="replace")
73
+ if "html" in content_type.casefold() or "<html" in text[:1000].casefold():
74
+ parser = VisibleTextParser()
75
+ parser.feed(text)
76
+ parser.close()
77
+ text = " ".join(parser.parts)
78
+ return re.sub(r"\s+", " ", text).strip()
79
+
80
+
81
+ def fingerprint_content(payload: bytes, content_type: str = "") -> tuple[str, int]:
82
+ normalized = normalize_content(payload, content_type)
83
+ if len(normalized) < MIN_NORMALIZED_CHARACTERS:
84
+ raise ValueError(
85
+ f"normalized response is too short ({len(normalized)} characters)"
86
+ )
87
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
88
+ return digest, len(normalized)
89
+
90
+
91
+ def fetch_source(url: str, timeout: float) -> dict:
92
+ request = urllib.request.Request(
93
+ url,
94
+ headers={
95
+ "Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
96
+ "Accept-Language": "en-US,en;q=0.8",
97
+ "User-Agent": BROWSER_USER_AGENT,
98
+ },
99
+ )
100
+ try:
101
+ with urllib.request.urlopen(request, timeout=timeout) as response:
102
+ status = response.status
103
+ content_type = response.headers.get("Content-Type", "")
104
+ payload = response.read(MAX_RESPONSE_BYTES + 1)
105
+ except urllib.error.HTTPError as error:
106
+ status_kind = "guarded" if error.code in GUARDED_STATUSES else "error"
107
+ return {
108
+ "status": status_kind,
109
+ "http_status": error.code,
110
+ "error": f"HTTP {error.code}",
111
+ }
112
+ except urllib.error.URLError as error:
113
+ return {"status": "error", "error": str(error.reason)}
114
+ except Exception as error: # noqa: BLE001
115
+ return {"status": "error", "error": str(error)}
116
+
117
+ if len(payload) > MAX_RESPONSE_BYTES:
118
+ return {
119
+ "status": "error",
120
+ "http_status": status,
121
+ "error": f"response exceeds {MAX_RESPONSE_BYTES} bytes",
122
+ }
123
+
124
+ try:
125
+ fingerprint, normalized_characters = fingerprint_content(payload, content_type)
126
+ except ValueError as error:
127
+ return {
128
+ "status": "error",
129
+ "http_status": status,
130
+ "error": str(error),
131
+ }
132
+ return {
133
+ "status": "success",
134
+ "http_status": status,
135
+ "fingerprint": fingerprint,
136
+ "normalized_characters": normalized_characters,
137
+ }
138
+
139
+
140
+ def previous_sources_by_url(previous: dict) -> dict[str, dict]:
141
+ sources: dict[str, dict] = {}
142
+ for collection in ("jurisdictions", "route_areas"):
143
+ for destination in previous.get(collection, []):
144
+ for source in destination.get("sources", []):
145
+ if source.get("url"):
146
+ sources[source["url"]] = source
147
+ return sources
148
+
149
+
150
+ def merge_source_result(
151
+ source: dict,
152
+ fetched: dict,
153
+ previous: dict | None,
154
+ checked_at: str,
155
+ ) -> dict:
156
+ result = {
157
+ "label": source["label"],
158
+ "url": source["url"],
159
+ "authority": source["authority"],
160
+ "roles": source["roles"],
161
+ }
162
+ previous = previous or {}
163
+ previous_fingerprint = previous.get("fingerprint")
164
+
165
+ if fetched["status"] == "success":
166
+ fingerprint = fetched["fingerprint"]
167
+ if not previous_fingerprint:
168
+ status = "baseline"
169
+ elif previous_fingerprint == fingerprint:
170
+ status = "unchanged"
171
+ elif previous.get("candidate_fingerprint") == fingerprint:
172
+ status = "changed"
173
+ else:
174
+ status = "change_candidate"
175
+
176
+ result.update(
177
+ {
178
+ "status": status,
179
+ "http_status": fetched["http_status"],
180
+ "last_success_at": checked_at,
181
+ }
182
+ )
183
+ if status == "change_candidate":
184
+ result.update(
185
+ {
186
+ "fingerprint": previous_fingerprint,
187
+ "normalized_characters": previous.get("normalized_characters"),
188
+ "candidate_fingerprint": fingerprint,
189
+ "candidate_normalized_characters": fetched[
190
+ "normalized_characters"
191
+ ],
192
+ "candidate_seen_at": checked_at,
193
+ }
194
+ )
195
+ else:
196
+ result.update(
197
+ {
198
+ "fingerprint": fingerprint,
199
+ "normalized_characters": fetched["normalized_characters"],
200
+ }
201
+ )
202
+ if status == "baseline":
203
+ result["baseline_at"] = checked_at
204
+ elif previous.get("baseline_at"):
205
+ result["baseline_at"] = previous["baseline_at"]
206
+ if status == "changed":
207
+ result["changed_from"] = previous_fingerprint
208
+ result["last_changed_at"] = checked_at
209
+ elif previous.get("last_changed_at"):
210
+ result["last_changed_at"] = previous["last_changed_at"]
211
+ return result
212
+
213
+ result["status"] = fetched["status"]
214
+ if fetched.get("http_status"):
215
+ result["http_status"] = fetched["http_status"]
216
+ result["error"] = fetched["error"]
217
+ for key in (
218
+ "fingerprint",
219
+ "normalized_characters",
220
+ "baseline_at",
221
+ "last_success_at",
222
+ "last_changed_at",
223
+ "candidate_fingerprint",
224
+ "candidate_normalized_characters",
225
+ "candidate_seen_at",
226
+ ):
227
+ if key in previous:
228
+ result[key] = previous[key]
229
+ return result
230
+
231
+
232
+ def aggregate_status(sources: list[dict]) -> str:
233
+ statuses = {source["status"] for source in sources}
234
+ if "changed" in statuses:
235
+ return "change_confirmed"
236
+ if "change_candidate" in statuses:
237
+ return "review_required"
238
+ if "baseline" in statuses:
239
+ return "baseline_created"
240
+ if statuses == {"unchanged"}:
241
+ return "unchanged"
242
+ if statuses & {"unchanged"} and statuses & {"guarded", "error"}:
243
+ return "partially_verified"
244
+ return "unverified"
245
+
246
+
247
+ def build_seeded_destination(seed: dict, source_results: dict[str, dict]) -> dict:
248
+ sources = [source_results[source["url"]] for source in seed["sources"]]
249
+ result = {
250
+ "id": seed["id"],
251
+ "name": seed["name"],
252
+ "coverage": "official_source_seeded",
253
+ "monitor_status": aggregate_status(sources),
254
+ "sources": sources,
255
+ }
256
+ if seed.get("iso2"):
257
+ result["iso2"] = seed["iso2"]
258
+ return result
259
+
260
+
261
+ def build_summary(
262
+ destinations: list[dict], route_areas: list[dict], all_sources: list[dict]
263
+ ) -> dict:
264
+ statuses = [source["status"] for source in all_sources]
265
+ return {
266
+ "destinations_total": len(destinations),
267
+ "destinations_seeded": sum(
268
+ item["coverage"] == "official_source_seeded" for item in destinations
269
+ ),
270
+ "destinations_requiring_live_discovery": sum(
271
+ item["coverage"] == "live_discovery_required" for item in destinations
272
+ ),
273
+ "route_areas_seeded": len(route_areas),
274
+ "sources_total": len(all_sources),
275
+ "sources_checked": sum(
276
+ status in {"baseline", "unchanged", "changed"} for status in statuses
277
+ ),
278
+ "sources_changed": statuses.count("changed"),
279
+ "sources_change_candidates": statuses.count("change_candidate"),
280
+ "sources_baselined": statuses.count("baseline"),
281
+ "sources_guarded": statuses.count("guarded"),
282
+ "sources_failed": statuses.count("error"),
283
+ "destinations_with_changes": sum(
284
+ item["monitor_status"] == "change_confirmed" for item in destinations
285
+ ),
286
+ "destinations_requiring_review": sum(
287
+ item["monitor_status"] == "review_required" for item in destinations
288
+ ),
289
+ "route_areas_with_changes": sum(
290
+ item["monitor_status"] == "change_confirmed" for item in route_areas
291
+ ),
292
+ "route_areas_requiring_review": sum(
293
+ item["monitor_status"] == "review_required" for item in route_areas
294
+ ),
295
+ }
296
+
297
+
298
+ def build_monitor(
299
+ directory: dict,
300
+ registry: dict,
301
+ previous: dict,
302
+ timeout: float,
303
+ workers: int,
304
+ fetcher: Callable[[str, float], dict] = fetch_source,
305
+ checked_at: str | None = None,
306
+ ) -> dict:
307
+ checked_at = checked_at or utc_timestamp()
308
+ previous_by_url = previous_sources_by_url(previous)
309
+ sources_by_url = {
310
+ source["url"]: source
311
+ for seed in registry["jurisdictions"]
312
+ for source in seed["sources"]
313
+ }
314
+ fetched_by_url: dict[str, dict] = {}
315
+ host_semaphores: defaultdict[str, threading.Semaphore] = defaultdict(
316
+ lambda: threading.Semaphore(MAX_CONNECTIONS_PER_HOST)
317
+ )
318
+
319
+ def throttled_fetch(url: str) -> dict:
320
+ hostname = urlparse(url).hostname or url
321
+ with host_semaphores[hostname]:
322
+ return fetcher(url, timeout)
323
+
324
+ with ThreadPoolExecutor(max_workers=workers) as executor:
325
+ futures = {
326
+ executor.submit(throttled_fetch, url): url for url in sources_by_url
327
+ }
328
+ for future in as_completed(futures):
329
+ url = futures[future]
330
+ try:
331
+ fetched_by_url[url] = future.result()
332
+ except Exception as error: # noqa: BLE001
333
+ fetched_by_url[url] = {"status": "error", "error": str(error)}
334
+
335
+ source_results = {
336
+ url: merge_source_result(
337
+ source,
338
+ fetched_by_url[url],
339
+ previous_by_url.get(url),
340
+ checked_at,
341
+ )
342
+ for url, source in sources_by_url.items()
343
+ }
344
+ seeds_by_iso2 = {
345
+ seed["iso2"]: seed
346
+ for seed in registry["jurisdictions"]
347
+ if seed.get("iso2")
348
+ }
349
+ destinations: list[dict] = []
350
+ for destination in directory["jurisdictions"]:
351
+ seed = seeds_by_iso2.get(destination["iso2"])
352
+ if seed:
353
+ destinations.append(build_seeded_destination(seed, source_results))
354
+ else:
355
+ destinations.append(
356
+ {
357
+ "id": destination["id"],
358
+ "iso2": destination["iso2"],
359
+ "name": destination["name"],
360
+ "coverage": "live_discovery_required",
361
+ "monitor_status": "not_monitored",
362
+ "sources": [],
363
+ }
364
+ )
365
+
366
+ route_areas = [
367
+ build_seeded_destination(seed, source_results)
368
+ for seed in registry["jurisdictions"]
369
+ if not seed.get("iso2")
370
+ ]
371
+ all_sources = list(source_results.values())
372
+ return {
373
+ "schema_version": 1,
374
+ "generated_at": checked_at,
375
+ "meaning": (
376
+ "A changed source fingerprint requires human review; it is not a visa "
377
+ "policy verdict. Unseeded destinations still require live official-source "
378
+ "discovery for each traveler and itinerary."
379
+ ),
380
+ "summary": build_summary(destinations, route_areas, all_sources),
381
+ "jurisdictions": destinations,
382
+ "route_areas": route_areas,
383
+ }
384
+
385
+
386
+ def markdown_summary(monitor: dict) -> str:
387
+ summary = monitor["summary"]
388
+ lines = [
389
+ "## Weekly visa policy source monitor",
390
+ "",
391
+ f"Checked at: `{monitor['generated_at']}`",
392
+ "",
393
+ "| Result | Count |",
394
+ "| --- | ---: |",
395
+ f"| Destinations in directory | {summary['destinations_total']} |",
396
+ f"| Destinations with registered official sources | {summary['destinations_seeded']} |",
397
+ "| Destinations requiring live source discovery | "
398
+ f"{summary['destinations_requiring_live_discovery']} |",
399
+ f"| Official pages checked | {summary['sources_checked']} / {summary['sources_total']} |",
400
+ f"| Changed page fingerprints | {summary['sources_changed']} |",
401
+ f"| New change candidates | {summary['sources_change_candidates']} |",
402
+ f"| Guarded pages | {summary['sources_guarded']} |",
403
+ f"| Failed pages | {summary['sources_failed']} |",
404
+ "",
405
+ "> A changed fingerprint is a review signal, not a legal or visa-policy conclusion.",
406
+ ]
407
+ changed = [
408
+ item
409
+ for item in (*monitor["jurisdictions"], *monitor["route_areas"])
410
+ if item["monitor_status"] in {"change_confirmed", "review_required"}
411
+ ]
412
+ if changed:
413
+ lines.extend(["", "### Sources requiring review", ""])
414
+ for item in changed:
415
+ changed_sources = [
416
+ source
417
+ for source in item["sources"]
418
+ if source["status"] in {"changed", "change_candidate"}
419
+ ]
420
+ lines.append(f"- **{item['name']}**")
421
+ lines.extend(
422
+ f" - [{source['status']}] {source['label']}: {source['url']}"
423
+ for source in changed_sources
424
+ )
425
+ return "\n".join(lines) + "\n"
426
+
427
+
428
+ def load_json(path: Path, default: dict) -> dict:
429
+ if not path.exists():
430
+ return default
431
+ return json.loads(path.read_text(encoding="utf-8"))
432
+
433
+
434
+ def main() -> None:
435
+ parser = argparse.ArgumentParser(description=__doc__)
436
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH)
437
+ parser.add_argument("--summary", type=Path)
438
+ parser.add_argument("--timeout", type=float, default=15.0)
439
+ parser.add_argument("--workers", type=int, default=8)
440
+ parser.add_argument(
441
+ "--reset",
442
+ action="store_true",
443
+ help="Ignore an existing monitor and create a fresh baseline",
444
+ )
445
+ args = parser.parse_args()
446
+ if args.workers < 1:
447
+ parser.error("--workers must be at least 1")
448
+
449
+ directory = load_json(DIRECTORY_PATH, {})
450
+ registry = load_json(REGISTRY_PATH, {})
451
+ previous = {} if args.reset else load_json(args.output, {})
452
+ monitor = build_monitor(
453
+ directory,
454
+ registry,
455
+ previous,
456
+ timeout=args.timeout,
457
+ workers=args.workers,
458
+ )
459
+ args.output.parent.mkdir(parents=True, exist_ok=True)
460
+ args.output.write_text(
461
+ json.dumps(monitor, ensure_ascii=False, indent=2) + "\n",
462
+ encoding="utf-8",
463
+ )
464
+ summary = markdown_summary(monitor)
465
+ if args.summary:
466
+ args.summary.parent.mkdir(parents=True, exist_ok=True)
467
+ with args.summary.open("a", encoding="utf-8") as summary_file:
468
+ summary_file.write(summary)
469
+ print(summary, end="")
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()