@yukioa2z/visa-apply 3.0.2 → 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.
- package/README.md +5 -1
- package/bin/install.js +79 -30
- package/package.json +1 -1
- package/skill/references/policy-monitor.json +3280 -0
- package/skill/scripts/policy_monitor.py +473 -0
|
@@ -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()
|