oddb2xml 3.0.25 → 3.0.26
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.
- checksums.yaml +4 -4
- data/.gitignore +3 -1
- data/CLAUDE.md +15 -2
- data/Elexis_Artikelstamm_v6.xsd +559 -0
- data/Gemfile.lock +1 -1
- data/History.txt +3 -0
- data/README.md +58 -1
- data/lib/oddb2xml/builder.rb +51 -11
- data/lib/oddb2xml/cli.rb +4 -4
- data/lib/oddb2xml/fhir_support.rb +6 -0
- data/lib/oddb2xml/options.rb +1 -1
- data/lib/oddb2xml/version.rb +1 -1
- data/scripts/generate_index_html.sh +202 -0
- data/scripts/run_oddb2xml.sh +38 -1
- data/scripts/setup_mediupdatexml_web.sh +110 -0
- data/scripts/swissmedic_watch.sh +85 -0
- data/scripts/visitor_stats.py +324 -0
- data/spec/artikelstamm_spec.rb +14 -14
- data/spec/fhir_spec.rb +47 -0
- metadata +6 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# swissmedic_watch.sh — recover the nightly build automatically after a
|
|
4
|
+
# Swissmedic outage/block.
|
|
5
|
+
#
|
|
6
|
+
# Background: since the Swissmedic platform migration (~2026-06-23, now on a
|
|
7
|
+
# Swisscom-operated gateway) www.swissmedic.ch resets this host's automated
|
|
8
|
+
# connections after the TLS handshake (TCP RST), so run_oddb2xml.sh aborts and
|
|
9
|
+
# the feeds go stale. The block may be a temporary migration artefact. Rather
|
|
10
|
+
# than rebuild blindly, this watcher polls Swissmedic with the *same* client
|
|
11
|
+
# oddb2xml uses (Ruby open-uri) and, the moment it answers again, kicks off one
|
|
12
|
+
# build — then emails. Meant to run every 30 min from /etc/crontab:
|
|
13
|
+
#
|
|
14
|
+
# */30 * * * * zdavatz /home/zdavatz/software/oddb2xml/scripts/swissmedic_watch.sh
|
|
15
|
+
#
|
|
16
|
+
# It is a no-op while Swissmedic is still blocked, while a build is already
|
|
17
|
+
# running, or once today's feeds are fresh — and it fires at most once per day.
|
|
18
|
+
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
22
|
+
OUT_DIR="${OUT_DIR:-/home/zdavatz/oddb2xml}"
|
|
23
|
+
BUILD_DIR="${BUILD_DIR:-${OUT_DIR%/}-build}"
|
|
24
|
+
# State (log + once-per-day stamp) lives OUTSIDE BUILD_DIR, which run_oddb2xml.sh
|
|
25
|
+
# deletes at the start of every build.
|
|
26
|
+
STATE_DIR="${STATE_DIR:-${OUT_DIR%/}-watch}"
|
|
27
|
+
ARTICLE_XML="$OUT_DIR/default/oddb_article.xml"
|
|
28
|
+
CANARY_URL="https://www.swissmedic.ch/swissmedic/de/home/services/listen_neu.html"
|
|
29
|
+
|
|
30
|
+
# Match the nightly cron's rbenv environment (the repo's .ruby-version points at
|
|
31
|
+
# a Ruby that isn't installed here; cron pins RBENV_VERSION instead).
|
|
32
|
+
export RBENV_VERSION="${RBENV_VERSION:-3.4.5}"
|
|
33
|
+
export PATH="/home/zdavatz/.rbenv/shims:/usr/bin:/bin"
|
|
34
|
+
|
|
35
|
+
mkdir -p "$STATE_DIR"
|
|
36
|
+
LOG="$STATE_DIR/swissmedic_watch.log"
|
|
37
|
+
today="$(date +%F)"
|
|
38
|
+
stamp="$STATE_DIR/.built.$today"
|
|
39
|
+
|
|
40
|
+
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG"; }
|
|
41
|
+
|
|
42
|
+
notify() { # best-effort local mail; silently skipped if no MTA
|
|
43
|
+
local subj="$1" body="${2:-$1}" sm=""
|
|
44
|
+
command -v sendmail >/dev/null 2>&1 && sm="$(command -v sendmail)"
|
|
45
|
+
[[ -z "$sm" && -x /usr/sbin/sendmail ]] && sm=/usr/sbin/sendmail
|
|
46
|
+
[[ -n "$sm" ]] && printf 'To: zdavatz\nSubject: %s\n\n%s\n' "$subj" "$body" | "$sm" -t 2>/dev/null || true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Already attempted a watcher-triggered build today? (set when we launch, so a
|
|
50
|
+
# failed build is not relaunched every 30 min — the nightly cron / a human can.)
|
|
51
|
+
[[ -e "$stamp" ]] && exit 0
|
|
52
|
+
|
|
53
|
+
# A build (nightly or earlier watcher) already running? Leave it alone.
|
|
54
|
+
if pgrep -f 'run_oddb2xml\.sh' >/dev/null 2>&1; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Today's feeds already fresh (nightly cron succeeded)? Nothing to do.
|
|
59
|
+
if [[ -f "$ARTICLE_XML" && "$(date -r "$ARTICLE_XML" +%F)" == "$today" ]]; then
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# Canary: can THIS host reach Swissmedic with oddb2xml's own client (open-uri)?
|
|
64
|
+
# Exit 0 only on HTTP 200; any reset/timeout/non-200 means still blocked.
|
|
65
|
+
if ! ruby -ropen-uri -e '
|
|
66
|
+
begin
|
|
67
|
+
URI.open(ARGV[0], open_timeout: 20, read_timeout: 25) { |f| exit(f.status[0].to_i == 200 ? 0 : 3) }
|
|
68
|
+
rescue => e
|
|
69
|
+
warn "#{e.class}: #{e.message}"; exit 1
|
|
70
|
+
end' "$CANARY_URL" >>"$LOG" 2>&1
|
|
71
|
+
then
|
|
72
|
+
# Still blocked — stay quiet (don't spam the log every 30 min; one line/day).
|
|
73
|
+
[[ -e "$STATE_DIR/.blocked.$today" ]] || { log "Swissmedic still unreachable; waiting."; : >"$STATE_DIR/.blocked.$today"; }
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# Reachable again and feeds are stale -> launch exactly one build.
|
|
78
|
+
: >"$stamp"
|
|
79
|
+
log "Swissmedic reachable again — launching run_oddb2xml.sh"
|
|
80
|
+
notify "oddb2xml: Swissmedic reachable again — build started" \
|
|
81
|
+
"Swissmedic answered the open-uri canary at $(date). Starting run_oddb2xml.sh; output in $STATE_DIR/run_oddb2xml.watch.log."
|
|
82
|
+
|
|
83
|
+
nohup "$SCRIPT_DIR/run_oddb2xml.sh" >>"$STATE_DIR/run_oddb2xml.watch.log" 2>&1 &
|
|
84
|
+
log "launched run_oddb2xml.sh (pid $!)"
|
|
85
|
+
exit 0
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# visitor_stats.py — build a small, self-contained "visitors & sessions" graph
|
|
3
|
+
# (inline SVG HTML fragment) for the mediupdatexml.oddb.org landing page from
|
|
4
|
+
# the Apache combined access log, broken down by day and by region (country).
|
|
5
|
+
#
|
|
6
|
+
# Besucher (visitors) = distinct client IPs per day
|
|
7
|
+
# Sitzungen (sessions) = 30-min-inactivity sessions per (IP, User-Agent) per day
|
|
8
|
+
# Regionen (regions) = top countries of the distinct IPs in the window
|
|
9
|
+
#
|
|
10
|
+
# Region resolution is fully self-contained: it uses the free DB-IP country-lite
|
|
11
|
+
# CSV (CC-BY 4.0, no licence key) cached next to the other runtime downloads and
|
|
12
|
+
# refreshed monthly — NO apt package, NO gem, NO system GeoIP database.
|
|
13
|
+
#
|
|
14
|
+
# Usage: visitor_stats.py LOG_GLOB CACHE_DIR [DAYS]
|
|
15
|
+
# LOG_GLOB e.g. "/var/log/apache2/mediupdatexml.oddb.org_access.log*"
|
|
16
|
+
# CACHE_DIR dir to cache the DB-IP CSV (the build downloads/ dir)
|
|
17
|
+
# DAYS window length, default 14
|
|
18
|
+
#
|
|
19
|
+
# Prints an HTML fragment to stdout on success. Prints NOTHING and exits 0 when
|
|
20
|
+
# the logs cannot be read or contain no usable data, so the caller can embed the
|
|
21
|
+
# output unconditionally and the page degrades gracefully.
|
|
22
|
+
|
|
23
|
+
import sys, os, re, gzip, glob, csv, bisect, html, datetime, urllib.request, ipaddress
|
|
24
|
+
|
|
25
|
+
DAYS = 14
|
|
26
|
+
SESSION_GAP = datetime.timedelta(minutes=30)
|
|
27
|
+
|
|
28
|
+
# Requests we don't count as human "visitors". Conservative: obvious crawlers,
|
|
29
|
+
# monitors and scripted clients. Empty UA ("-") is also dropped.
|
|
30
|
+
BOT_RE = re.compile(
|
|
31
|
+
r"bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|"
|
|
32
|
+
r"monitor|uptime|pingdom|statuscake|nagios|zabbix|"
|
|
33
|
+
r"curl|wget|python-requests|go-http|libwww|httpclient|okhttp|"
|
|
34
|
+
r"scan|nmap|masscan|semrush|ahrefs|mj12|dotbot|petalbot|dataprovider",
|
|
35
|
+
re.I,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Combined log: IP - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.0" 200 2326 "ref" "UA"
|
|
39
|
+
LINE_RE = re.compile(
|
|
40
|
+
r'^(?P<ip>\S+)\s+\S+\s+\S+\s+\[(?P<ts>[^\]]+)\]\s+'
|
|
41
|
+
r'"[^"]*"\s+\d{3}\s+\S+\s+"[^"]*"\s+"(?P<ua>[^"]*)"'
|
|
42
|
+
)
|
|
43
|
+
TS_FMT = "%d/%b/%Y:%H:%M:%S %z"
|
|
44
|
+
|
|
45
|
+
# Minimal CH-relevant country code -> (German name, flag emoji). Anything not
|
|
46
|
+
# listed falls back to the bare code; ZZ/unknown -> "Unbekannt".
|
|
47
|
+
CC_NAMES = {
|
|
48
|
+
"CH": "Schweiz", "DE": "Deutschland", "AT": "Österreich", "FR": "Frankreich",
|
|
49
|
+
"IT": "Italien", "LI": "Liechtenstein", "US": "USA", "GB": "Grossbritannien",
|
|
50
|
+
"NL": "Niederlande", "BE": "Belgien", "ES": "Spanien", "PT": "Portugal",
|
|
51
|
+
"PL": "Polen", "CZ": "Tschechien", "SE": "Schweden", "DK": "Dänemark",
|
|
52
|
+
"NO": "Norwegen", "FI": "Finnland", "IE": "Irland", "RU": "Russland",
|
|
53
|
+
"CN": "China", "IN": "Indien", "JP": "Japan", "BR": "Brasilien",
|
|
54
|
+
"CA": "Kanada", "AU": "Australien", "UA": "Ukraine", "TR": "Türkei",
|
|
55
|
+
"RO": "Rumänien", "HU": "Ungarn", "GR": "Griechenland", "LU": "Luxemburg",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def flag(cc):
|
|
60
|
+
if not cc or len(cc) != 2 or cc == "ZZ" or not cc.isalpha():
|
|
61
|
+
return "🏳"
|
|
62
|
+
return chr(0x1F1E6 + ord(cc[0].upper()) - 65) + chr(0x1F1E6 + ord(cc[1].upper()) - 65)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cc_label(cc):
|
|
66
|
+
if not cc or cc == "ZZ":
|
|
67
|
+
return ("🏳", "Unbekannt")
|
|
68
|
+
return (flag(cc), CC_NAMES.get(cc, cc))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------- DB-IP CSV ---
|
|
72
|
+
def ensure_dbip(cache_dir):
|
|
73
|
+
"""Return path to a current DB-IP country-lite CSV, downloading if needed."""
|
|
74
|
+
month = datetime.date.today().strftime("%Y-%m")
|
|
75
|
+
path = os.path.join(cache_dir, f"dbip-country-lite-{month}.csv")
|
|
76
|
+
if os.path.exists(path) and os.path.getsize(path) > 1_000_000:
|
|
77
|
+
return path
|
|
78
|
+
url = f"https://download.db-ip.com/free/dbip-country-lite-{month}.csv.gz"
|
|
79
|
+
try:
|
|
80
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
81
|
+
req = urllib.request.Request(url, headers={"User-Agent": "oddb2xml-stats/1.0"})
|
|
82
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
83
|
+
data = gzip.decompress(r.read())
|
|
84
|
+
tmp = path + ".tmp"
|
|
85
|
+
with open(tmp, "wb") as f:
|
|
86
|
+
f.write(data)
|
|
87
|
+
os.replace(tmp, path)
|
|
88
|
+
# prune older months so the cache doesn't grow unbounded
|
|
89
|
+
for old in glob.glob(os.path.join(cache_dir, "dbip-country-lite-*.csv")):
|
|
90
|
+
if old != path:
|
|
91
|
+
try:
|
|
92
|
+
os.remove(old)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
return path
|
|
96
|
+
except Exception:
|
|
97
|
+
# fall back to any cached copy we already have
|
|
98
|
+
existing = sorted(glob.glob(os.path.join(cache_dir, "dbip-country-lite-*.csv")))
|
|
99
|
+
return existing[-1] if existing else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_geo(csv_path):
|
|
103
|
+
"""Load DB-IP CSV into sorted (start_int -> cc) tables for v4 and v6."""
|
|
104
|
+
v4s, v4e, v4c, v6s, v6e, v6c = [], [], [], [], [], []
|
|
105
|
+
with open(csv_path, newline="") as f:
|
|
106
|
+
for row in csv.reader(f):
|
|
107
|
+
if len(row) < 3:
|
|
108
|
+
continue
|
|
109
|
+
start, end, cc = row[0], row[1], row[2]
|
|
110
|
+
try:
|
|
111
|
+
if ":" in start:
|
|
112
|
+
v6s.append(int(ipaddress.IPv6Address(start)))
|
|
113
|
+
v6e.append(int(ipaddress.IPv6Address(end)))
|
|
114
|
+
v6c.append(cc)
|
|
115
|
+
else:
|
|
116
|
+
v4s.append(int(ipaddress.IPv4Address(start)))
|
|
117
|
+
v4e.append(int(ipaddress.IPv4Address(end)))
|
|
118
|
+
v4c.append(cc)
|
|
119
|
+
except ipaddress.AddressValueError:
|
|
120
|
+
continue
|
|
121
|
+
return (v4s, v4e, v4c, v6s, v6e, v6c)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def lookup(geo, ip_str):
|
|
125
|
+
v4s, v4e, v4c, v6s, v6e, v6c = geo
|
|
126
|
+
try:
|
|
127
|
+
ip = ipaddress.ip_address(ip_str)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return "ZZ"
|
|
130
|
+
n = int(ip)
|
|
131
|
+
if ip.version == 4:
|
|
132
|
+
starts, ends, ccs = v4s, v4e, v4c
|
|
133
|
+
else:
|
|
134
|
+
starts, ends, ccs = v6s, v6e, v6c
|
|
135
|
+
i = bisect.bisect_right(starts, n) - 1
|
|
136
|
+
if 0 <= i < len(starts) and n <= ends[i]:
|
|
137
|
+
return ccs[i] or "ZZ"
|
|
138
|
+
return "ZZ"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------- parse ---
|
|
142
|
+
def open_log(path):
|
|
143
|
+
return gzip.open(path, "rt", errors="replace") if path.endswith(".gz") \
|
|
144
|
+
else open(path, "rt", errors="replace")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_logs(log_glob, days):
|
|
148
|
+
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=days)
|
|
149
|
+
# per day: set of IPs (visitors); per (ip,ua): sorted timestamps for sessions
|
|
150
|
+
day_ips = {} # "YYYY-MM-DD" -> set(ip)
|
|
151
|
+
last_seen = {} # (day, ip, ua) -> last datetime (for session gaps)
|
|
152
|
+
day_sessions = {} # day -> session count
|
|
153
|
+
all_ips = set()
|
|
154
|
+
any_line = False
|
|
155
|
+
|
|
156
|
+
files = sorted(glob.glob(log_glob))
|
|
157
|
+
for path in files:
|
|
158
|
+
try:
|
|
159
|
+
fh = open_log(path)
|
|
160
|
+
except OSError:
|
|
161
|
+
continue
|
|
162
|
+
with fh:
|
|
163
|
+
for line in fh:
|
|
164
|
+
m = LINE_RE.match(line)
|
|
165
|
+
if not m:
|
|
166
|
+
continue
|
|
167
|
+
ua = m.group("ua")
|
|
168
|
+
if not ua or ua == "-" or BOT_RE.search(ua):
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
ts = datetime.datetime.strptime(m.group("ts"), TS_FMT)
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
if ts.astimezone(datetime.timezone.utc) < cutoff:
|
|
175
|
+
continue
|
|
176
|
+
any_line = True
|
|
177
|
+
ip = m.group("ip")
|
|
178
|
+
day = ts.strftime("%Y-%m-%d")
|
|
179
|
+
day_ips.setdefault(day, set()).add(ip)
|
|
180
|
+
all_ips.add(ip)
|
|
181
|
+
key = (day, ip, ua)
|
|
182
|
+
prev = last_seen.get(key)
|
|
183
|
+
if prev is None or (ts - prev) > SESSION_GAP:
|
|
184
|
+
day_sessions[day] = day_sessions.get(day, 0) + 1
|
|
185
|
+
last_seen[key] = ts
|
|
186
|
+
if not any_line:
|
|
187
|
+
return None
|
|
188
|
+
return day_ips, day_sessions, all_ips
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ render ---
|
|
192
|
+
def esc(s):
|
|
193
|
+
return html.escape(str(s), quote=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render(day_ips, day_sessions, region_counts, days):
|
|
197
|
+
today = datetime.date.today()
|
|
198
|
+
span = [(today - datetime.timedelta(days=i)) for i in range(days - 1, -1, -1)]
|
|
199
|
+
labels = [d.strftime("%Y-%m-%d") for d in span]
|
|
200
|
+
visitors = [len(day_ips.get(l, ())) for l in labels]
|
|
201
|
+
sessions = [day_sessions.get(l, 0) for l in labels]
|
|
202
|
+
peak = max(visitors + sessions + [1])
|
|
203
|
+
|
|
204
|
+
# geometry
|
|
205
|
+
W, H = 760, 200
|
|
206
|
+
pad_l, pad_r, pad_t, pad_b = 34, 12, 14, 34
|
|
207
|
+
plot_w = W - pad_l - pad_r
|
|
208
|
+
plot_h = H - pad_t - pad_b
|
|
209
|
+
n = len(labels)
|
|
210
|
+
slot = plot_w / n
|
|
211
|
+
bw = min(slot * 0.36, 16) # bar width per series
|
|
212
|
+
gap = bw * 0.15
|
|
213
|
+
|
|
214
|
+
def x_of(i):
|
|
215
|
+
return pad_l + slot * i + slot / 2
|
|
216
|
+
|
|
217
|
+
def y_of(v):
|
|
218
|
+
return pad_t + plot_h - (v / peak) * plot_h
|
|
219
|
+
|
|
220
|
+
parts = [f'<svg viewBox="0 0 {W} {H}" width="100%" role="img" '
|
|
221
|
+
f'aria-label="Besucher und Sitzungen pro Tag" '
|
|
222
|
+
f'style="max-width:{W}px;font-family:system-ui,sans-serif">']
|
|
223
|
+
|
|
224
|
+
# y gridlines + labels (0, mid, peak)
|
|
225
|
+
for frac in (0, 0.5, 1):
|
|
226
|
+
val = round(peak * frac)
|
|
227
|
+
y = y_of(val)
|
|
228
|
+
parts.append(f'<line x1="{pad_l}" y1="{y:.1f}" x2="{W-pad_r}" y2="{y:.1f}" '
|
|
229
|
+
f'stroke="#e6ecf5" stroke-width="1"/>')
|
|
230
|
+
parts.append(f'<text x="{pad_l-6}" y="{y+3:.1f}" text-anchor="end" '
|
|
231
|
+
f'font-size="9" fill="#9aa6b2">{val}</text>')
|
|
232
|
+
|
|
233
|
+
# bars
|
|
234
|
+
for i in range(n):
|
|
235
|
+
cx = x_of(i)
|
|
236
|
+
vx = cx - bw - gap / 2
|
|
237
|
+
sx = cx + gap / 2
|
|
238
|
+
vy, sy = y_of(visitors[i]), y_of(sessions[i])
|
|
239
|
+
parts.append(f'<rect x="{vx:.1f}" y="{vy:.1f}" width="{bw:.1f}" '
|
|
240
|
+
f'height="{pad_t+plot_h-vy:.1f}" rx="1.5" fill="#0a58ca">'
|
|
241
|
+
f'<title>{esc(labels[i])}: {visitors[i]} Besucher</title></rect>')
|
|
242
|
+
parts.append(f'<rect x="{sx:.1f}" y="{sy:.1f}" width="{bw:.1f}" '
|
|
243
|
+
f'height="{pad_t+plot_h-sy:.1f}" rx="1.5" fill="#7eb0f4">'
|
|
244
|
+
f'<title>{esc(labels[i])}: {sessions[i]} Sitzungen</title></rect>')
|
|
245
|
+
# x label: short day (only every other if crowded)
|
|
246
|
+
if n <= 16 or i % 2 == 0:
|
|
247
|
+
parts.append(f'<text x="{cx:.1f}" y="{H-pad_b+13}" text-anchor="middle" '
|
|
248
|
+
f'font-size="9" fill="#7a8896">{esc(span[i].strftime("%d.%m"))}</text>')
|
|
249
|
+
|
|
250
|
+
parts.append("</svg>")
|
|
251
|
+
chart = "".join(parts)
|
|
252
|
+
|
|
253
|
+
# legend
|
|
254
|
+
legend = ('<div class="vs-legend">'
|
|
255
|
+
'<span><i style="background:#0a58ca"></i>Besucher (eindeutige IP/Tag)</span>'
|
|
256
|
+
'<span><i style="background:#7eb0f4"></i>Sitzungen (30-Min-Inaktivität)</span>'
|
|
257
|
+
'</div>')
|
|
258
|
+
|
|
259
|
+
# region bars
|
|
260
|
+
total_ips = sum(c for _, c in region_counts) or 1
|
|
261
|
+
rows = []
|
|
262
|
+
for cc, cnt in region_counts:
|
|
263
|
+
fl, name = cc_label(cc)
|
|
264
|
+
pct = cnt / total_ips * 100
|
|
265
|
+
rows.append(
|
|
266
|
+
f'<div class="vs-row"><span class="vs-cc">{fl} {esc(name)}</span>'
|
|
267
|
+
f'<span class="vs-bar"><i style="width:{pct:.1f}%"></i></span>'
|
|
268
|
+
f'<span class="vs-num">{cnt}</span></div>')
|
|
269
|
+
regions = ('<div class="vs-regions"><div class="vs-rt">Regionen (nach IP)</div>'
|
|
270
|
+
+ "".join(rows) + "</div>")
|
|
271
|
+
|
|
272
|
+
tot_v = sum(visitors)
|
|
273
|
+
tot_s = sum(sessions)
|
|
274
|
+
style = (
|
|
275
|
+
"<style>"
|
|
276
|
+
".vs-wrap{margin:.4rem 0 0}"
|
|
277
|
+
".vs-legend{display:flex;gap:1.2rem;flex-wrap:wrap;font-size:.8rem;color:#555;margin:.3rem 0 .8rem}"
|
|
278
|
+
".vs-legend i{display:inline-block;width:11px;height:11px;border-radius:2px;margin-right:.35rem;vertical-align:-1px}"
|
|
279
|
+
".vs-regions{margin-top:1rem;max-width:480px}"
|
|
280
|
+
".vs-rt{font-size:.85rem;color:#555;margin-bottom:.4rem}"
|
|
281
|
+
".vs-row{display:flex;align-items:center;gap:.6rem;margin:.18rem 0;font-size:.85rem}"
|
|
282
|
+
".vs-cc{flex:0 0 150px}"
|
|
283
|
+
".vs-bar{flex:1;background:#eef2f8;border-radius:4px;height:11px;overflow:hidden}"
|
|
284
|
+
".vs-bar i{display:block;height:100%;background:#0a58ca}"
|
|
285
|
+
".vs-num{flex:0 0 46px;text-align:right;color:#555;font-variant-numeric:tabular-nums}"
|
|
286
|
+
"</style>"
|
|
287
|
+
)
|
|
288
|
+
return (
|
|
289
|
+
f'{style}<h2>Zugriffe (letzte {days} Tage, ohne Bots)</h2>'
|
|
290
|
+
f'<div class="vs-wrap">{legend}{chart}'
|
|
291
|
+
f'<p class="desc">Summe: {tot_v} Besucher · {tot_s} Sitzungen · '
|
|
292
|
+
f'{len(region_counts)} Regionen.</p>{regions}</div>'
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def main():
|
|
297
|
+
if len(sys.argv) < 3:
|
|
298
|
+
return 0
|
|
299
|
+
log_glob, cache_dir = sys.argv[1], sys.argv[2]
|
|
300
|
+
days = int(sys.argv[3]) if len(sys.argv) > 3 else DAYS
|
|
301
|
+
|
|
302
|
+
parsed = parse_logs(log_glob, days)
|
|
303
|
+
if not parsed:
|
|
304
|
+
return 0 # no readable/usable logs -> emit nothing
|
|
305
|
+
day_ips, day_sessions, all_ips = parsed
|
|
306
|
+
|
|
307
|
+
region_counts = []
|
|
308
|
+
csv_path = ensure_dbip(cache_dir)
|
|
309
|
+
if csv_path:
|
|
310
|
+
geo = load_geo(csv_path)
|
|
311
|
+
counts = {}
|
|
312
|
+
for ip in all_ips:
|
|
313
|
+
counts[lookup(geo, ip)] = counts.get(lookup(geo, ip), 0) + 1
|
|
314
|
+
region_counts = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:6]
|
|
315
|
+
|
|
316
|
+
sys.stdout.write(render(day_ips, day_sessions, region_counts, days))
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
try:
|
|
322
|
+
sys.exit(main())
|
|
323
|
+
except Exception:
|
|
324
|
+
sys.exit(0) # never break the page build
|
data/spec/artikelstamm_spec.rb
CHANGED
|
@@ -20,12 +20,12 @@ describe Oddb2xml::Builder do
|
|
|
20
20
|
@saved_dir = Dir.pwd
|
|
21
21
|
@oddb2xml_xsd = File.expand_path(File.join(File.dirname(__FILE__), "..", "oddb2xml.xsd"))
|
|
22
22
|
@oddb_calc_xsd = File.expand_path(File.join(File.dirname(__FILE__), "..", "oddb_calc.xsd"))
|
|
23
|
-
@
|
|
24
|
-
@
|
|
23
|
+
@elexis_v6_xsd = File.expand_path(File.join(__FILE__, "..", "..", "Elexis_Artikelstamm_v6.xsd"))
|
|
24
|
+
@elexis_v6_csv = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.csv")
|
|
25
25
|
|
|
26
26
|
expect(File.exist?(@oddb2xml_xsd)).to eq true
|
|
27
27
|
expect(File.exist?(@oddb_calc_xsd)).to eq true
|
|
28
|
-
expect(File.exist?(@
|
|
28
|
+
expect(File.exist?(@elexis_v6_xsd)).to eq true
|
|
29
29
|
cleanup_directories_before_run
|
|
30
30
|
FileUtils.makedirs(Oddb2xml::WORK_DIR)
|
|
31
31
|
Dir.chdir(Oddb2xml::WORK_DIR)
|
|
@@ -45,7 +45,7 @@ describe Oddb2xml::Builder do
|
|
|
45
45
|
options = Oddb2xml::Options.parse(["--artikelstamm", "--no-fhir"]) # , '--log'])
|
|
46
46
|
# @res = buildr_capture(:stdout){ Oddb2xml::Cli.new(options).run }
|
|
47
47
|
Oddb2xml::Cli.new(options).run # to debug
|
|
48
|
-
@artikelstamm_name = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}
|
|
48
|
+
@artikelstamm_name = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.xml")
|
|
49
49
|
@doc = Nokogiri::XML(File.open(@artikelstamm_name))
|
|
50
50
|
# @rexml = REXML::Document.new File.read(@artikelstamm_name)
|
|
51
51
|
@inhalt = IO.read(@artikelstamm_name)
|
|
@@ -101,20 +101,20 @@ describe Oddb2xml::Builder do
|
|
|
101
101
|
expect(@inhalt.index(expected)).not_to be nil
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
it "should produce a
|
|
105
|
-
expect(File.exist?(@
|
|
106
|
-
inhalt = File.open(@
|
|
104
|
+
it "should produce a Elexis_Artikelstamm_v6.csv" do
|
|
105
|
+
expect(File.exist?(@elexis_v6_csv)).to eq true
|
|
106
|
+
inhalt = File.open(@elexis_v6_csv, "r+").read
|
|
107
107
|
expect(inhalt.size).to be > 0
|
|
108
108
|
expect(inhalt).to match(/7680284860144/)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
it "should NOT generate a v3 nonpharma xml" do
|
|
112
|
-
v3_name = @artikelstamm_name.sub("
|
|
112
|
+
v3_name = @artikelstamm_name.sub("_v6.xml", "_v3.xml").sub("artikelstamm_", "artikelstamm_N_")
|
|
113
113
|
expect(File.exist?(v3_name)).to eq false
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
it "should NOT generate a vx pharma xml" do
|
|
117
|
-
v3_name = @artikelstamm_name.sub("
|
|
117
|
+
v3_name = @artikelstamm_name.sub("_v6.xml", "_v3.xml").sub("artikelstamm_", "artikelstamm_P_")
|
|
118
118
|
expect(File.exist?(v3_name)).to eq false
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -123,8 +123,8 @@ describe Oddb2xml::Builder do
|
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
it "should find price from Preparations.xml by setting" do
|
|
126
|
-
expect(File.exist?(@
|
|
127
|
-
inhalt = File.open(@
|
|
126
|
+
expect(File.exist?(@elexis_v6_csv)).to eq true
|
|
127
|
+
inhalt = File.open(@elexis_v6_csv, "r+").read
|
|
128
128
|
expected = %(7680658560014,Dibase 10'000 Tropfen 10000 IE/ml Fl 10 ml,,Flasche(n),5,9.25,6585601,A11CC05,,"",,SL)
|
|
129
129
|
expect(inhalt.index(expected)).to be > 0
|
|
130
130
|
end
|
|
@@ -141,8 +141,8 @@ describe Oddb2xml::Builder do
|
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
it "should have a price for Lynparza" do
|
|
144
|
-
expect(File.exist?(@
|
|
145
|
-
inhalt = File.open(@
|
|
144
|
+
expect(File.exist?(@elexis_v6_csv)).to eq true
|
|
145
|
+
inhalt = File.open(@elexis_v6_csv, "r+").read
|
|
146
146
|
expect(inhalt.index('7680651600014,Lynparza Kaps 50 mg 448 Stk,,Kapsel(n),5562.48,5947.55,6516001,L01XX46,,"",,S')).not_to be nil
|
|
147
147
|
end
|
|
148
148
|
it "should trim the ean13 to 13 length" do
|
|
@@ -240,7 +240,7 @@ describe Oddb2xml::Builder do
|
|
|
240
240
|
# Should also check for price!
|
|
241
241
|
end
|
|
242
242
|
it "should validate against artikelstamm.xsd" do
|
|
243
|
-
validate_via_xsd(@
|
|
243
|
+
validate_via_xsd(@elexis_v6_xsd, @artikelstamm_name)
|
|
244
244
|
end
|
|
245
245
|
tests = {"item 7680403330459 CARBADERM only in Preparations(SL) with public price" =>
|
|
246
246
|
%(<ITEM PHARMATYPE="P">
|
data/spec/fhir_spec.rb
CHANGED
|
@@ -87,6 +87,19 @@ describe "FHIR Indikationscode support" do
|
|
|
87
87
|
pkg = item[:packages].values.first
|
|
88
88
|
expect(pkg[:indication_codes]).to eq(item[:indication_codes])
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
it "carries the indication code (INDCD) and validity dates on each package limitation" do
|
|
92
|
+
data = described_class.new(cyramza_fixture).to_hash
|
|
93
|
+
pkg = data.values.first[:packages].values.first
|
|
94
|
+
by_code = pkg[:limitations].each_with_object({}) { |l, h| h[l[:code]] = l }
|
|
95
|
+
|
|
96
|
+
# INDCD is the BAG Indikationscode, independent of the CUD id (= LIMCD).
|
|
97
|
+
expect(by_code["CYRAMZA.01"][:indcd]).to eq("20403.01")
|
|
98
|
+
expect(by_code["CYRAMZA.02"][:indcd]).to eq("20403.02")
|
|
99
|
+
# Validity dates feed <VDAT>/<VTDAT> in the v6 <ARTSL> block.
|
|
100
|
+
expect(by_code["CYRAMZA.01"][:vdate]).to eq("2024-10-01")
|
|
101
|
+
expect(by_code["CYRAMZA.02"][:vtdate]).to eq("2027-09-30")
|
|
102
|
+
end
|
|
90
103
|
end
|
|
91
104
|
|
|
92
105
|
describe Oddb2xml::FhirExtractor, "limitation text resolution" do
|
|
@@ -209,4 +222,38 @@ describe "FHIR Indikationscode support" do
|
|
|
209
222
|
expect(xml).to include("In Kombination mit FOLFIRI")
|
|
210
223
|
end
|
|
211
224
|
end
|
|
225
|
+
|
|
226
|
+
describe Oddb2xml::Builder, "v6 ARTSL/INDCD emission" do
|
|
227
|
+
def artsl_xml(limitations)
|
|
228
|
+
builder = described_class.new
|
|
229
|
+
Nokogiri::XML::Builder.new(encoding: "utf-8") do |xml|
|
|
230
|
+
builder.send(:append_artsl, xml, limitations)
|
|
231
|
+
end.to_xml
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "emits an <ARTSL> block with one <ARTLIM> per indication code" do
|
|
235
|
+
data = Oddb2xml::FhirExtractor.new(cyramza_fixture).to_hash
|
|
236
|
+
lims = data.values.first[:packages].values.first[:limitations]
|
|
237
|
+
xml = artsl_xml(lims)
|
|
238
|
+
|
|
239
|
+
expect(xml).to include("<ARTSL>")
|
|
240
|
+
# PM is true because the BAG indication code is required only for SL
|
|
241
|
+
# price-model drugs, which is exactly what reaches this block.
|
|
242
|
+
expect(xml).to include("<PM>true</PM>")
|
|
243
|
+
expect(xml).to include("<LIMCD>CYRAMZA.01</LIMCD>")
|
|
244
|
+
expect(xml).to include("<INDCD>20403.01</INDCD>")
|
|
245
|
+
expect(xml).to include("<VDAT>2024-10-01T00:00:00</VDAT>")
|
|
246
|
+
expect(xml).to include("<LIMCD>CYRAMZA.02</LIMCD>")
|
|
247
|
+
expect(xml).to include("<INDCD>20403.02</INDCD>")
|
|
248
|
+
# End date present only for CYRAMZA.02 -> only that ARTLIM gets a <VTDAT>.
|
|
249
|
+
expect(xml).to include("<VTDAT>2027-09-30T00:00:00</VTDAT>")
|
|
250
|
+
expect(xml.scan("<VTDAT>").size).to eq(1)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "omits <ARTSL> when no limitation carries an indication code" do
|
|
254
|
+
expect(artsl_xml([{code: "X", indcd: "", vdate: "2020-01-01"}])).not_to include("<ARTSL>")
|
|
255
|
+
expect(artsl_xml([])).not_to include("<ARTSL>")
|
|
256
|
+
expect(artsl_xml(nil)).not_to include("<ARTSL>")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
212
259
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: oddb2xml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.
|
|
4
|
+
version: 3.0.26
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yasuhiro Asaka, Zeno R.R. Davatz, Niklaus Giger
|
|
@@ -449,6 +449,7 @@ files:
|
|
|
449
449
|
- CLAUDE.md
|
|
450
450
|
- Elexis_Artikelstamm_v003.xsd
|
|
451
451
|
- Elexis_Artikelstamm_v5.xsd
|
|
452
|
+
- Elexis_Artikelstamm_v6.xsd
|
|
452
453
|
- Gemfile
|
|
453
454
|
- Gemfile.lock
|
|
454
455
|
- History.txt
|
|
@@ -496,8 +497,12 @@ files:
|
|
|
496
497
|
- oddb2xml.gemspec
|
|
497
498
|
- oddb2xml.xsd
|
|
498
499
|
- oddb_calc.xsd
|
|
500
|
+
- scripts/generate_index_html.sh
|
|
499
501
|
- scripts/run_oddb2xml.sh
|
|
502
|
+
- scripts/setup_mediupdatexml_web.sh
|
|
503
|
+
- scripts/swissmedic_watch.sh
|
|
500
504
|
- scripts/transfer.sh
|
|
505
|
+
- scripts/visitor_stats.py
|
|
501
506
|
- spec/artikelstamm_spec.rb
|
|
502
507
|
- spec/builder_spec.rb
|
|
503
508
|
- spec/calc_spec.rb
|