opencode-bioresearcher 1.6.0

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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +103 -0
  3. package/agents/bioresearcher-dr-worker.md +54 -0
  4. package/connector-meta.json +23 -0
  5. package/index.js +77 -0
  6. package/loader.js +3 -0
  7. package/package.json +42 -0
  8. package/skill-bundle.json +12 -0
  9. package/skills/bioresearcher-deep-research/SKILL.md +330 -0
  10. package/skills/bioresearcher-deep-research/references/analysis-methods.md +90 -0
  11. package/skills/bioresearcher-deep-research/references/article-literature.md +89 -0
  12. package/skills/bioresearcher-deep-research/references/best-practices.md +102 -0
  13. package/skills/bioresearcher-deep-research/references/citations.md +146 -0
  14. package/skills/bioresearcher-deep-research/references/clinical-trials.md +87 -0
  15. package/skills/bioresearcher-deep-research/references/diseases.md +94 -0
  16. package/skills/bioresearcher-deep-research/references/drugs.md +88 -0
  17. package/skills/bioresearcher-deep-research/references/ensembl-pdb.md +134 -0
  18. package/skills/bioresearcher-deep-research/references/functional-genomics.md +118 -0
  19. package/skills/bioresearcher-deep-research/references/genes.md +93 -0
  20. package/skills/bioresearcher-deep-research/references/optional-analysis.md +108 -0
  21. package/skills/bioresearcher-deep-research/references/patents.md +92 -0
  22. package/skills/bioresearcher-deep-research/references/rate-limiting-auth.md +95 -0
  23. package/skills/bioresearcher-deep-research/references/report-template.md +117 -0
  24. package/skills/bioresearcher-deep-research/references/tool-selection.md +142 -0
  25. package/skills/bioresearcher-deep-research/references/utility-config.md +116 -0
  26. package/skills/bioresearcher-deep-research/references/variants.md +109 -0
  27. package/skills/bioresearcher-deep-research/references/worker-protocol.md +110 -0
  28. package/skills/bioresearcher-deep-research/scripts/markdown-to-html.py +86 -0
  29. package/skills/bioresearcher-plot-making/SKILL.md +97 -0
  30. package/skills/bioresearcher-plot-making/references/literature-search-method-summary.md +163 -0
  31. package/skills/bioresearcher-plot-making/references/qa-gates-and-gotchas.md +156 -0
  32. package/skills/bioresearcher-plot-making/references/structural-biology-binder-visualization.md +206 -0
  33. package/skills/bioresearcher-plot-making/scripts/audit_figure_collisions.py +742 -0
  34. package/skills/bioresearcher-plot-making/scripts/audit_panel_alignment.py +935 -0
  35. package/skills/bioresearcher-plot-making/scripts/audit_pdf_text.py +152 -0
  36. package/skills/bioresearcher-plot-making/scripts/plot_helpers.py +177 -0
  37. package/skills/bioresearcher-pubmed-weekly/SKILL.md +223 -0
  38. package/skills/bioresearcher-pubmed-weekly/scripts/parse_updatefiles.py +272 -0
  39. package/skills/bioresearcher-pubmed-weekly/scripts/pubmed_weekly.py +493 -0
  40. package/skills/bioresearcher-python-setup-uv/SKILL.md +184 -0
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """Audit text font sizes used by PDF content-stream ``Tf`` operators.
3
+
4
+ This dependency-free check catches reduced mathtext superscripts/subscripts and
5
+ other glyph runs that can fall below a journal font-size floor even when the
6
+ parent matplotlib ``fontsize`` is compliant. It supports plain and FlateDecode
7
+ content streams, which covers normal matplotlib PDF output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ import sys
16
+ import zlib
17
+ from dataclasses import asdict, dataclass
18
+ from pathlib import Path
19
+
20
+
21
+ STREAM_START = re.compile(rb"stream\r?\n")
22
+ TF_OPERATOR = re.compile(
23
+ rb"/([^\s/<>]+)\s+([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][-+]?\d+)?)\s+Tf\b"
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TextRun:
29
+ stream: int
30
+ font: str
31
+ size_pt: float
32
+
33
+
34
+ def decoded_streams(data: bytes) -> tuple[list[bytes], list[str]]:
35
+ streams: list[bytes] = []
36
+ warnings: list[str] = []
37
+ cursor = 0
38
+ stream_number = 0
39
+ while True:
40
+ match = STREAM_START.search(data, cursor)
41
+ if not match:
42
+ break
43
+ stream_number += 1
44
+ end = data.find(b"endstream", match.end())
45
+ if end < 0:
46
+ warnings.append(f"stream {stream_number} has no endstream marker")
47
+ break
48
+ # Keep the raw stream bytes. zlib accepts PDF's trailing line break,
49
+ # while stripping could accidentally remove a legitimate compressed
50
+ # byte that happens to equal CR or LF.
51
+ payload = data[match.end() : end]
52
+ header = data[max(0, match.start() - 2048) : match.start()]
53
+ dictionary_start = header.rfind(b"<<")
54
+ dictionary = header[dictionary_start:] if dictionary_start >= 0 else header
55
+ if b"/FlateDecode" in dictionary:
56
+ try:
57
+ payload = zlib.decompress(payload)
58
+ except zlib.error as exc:
59
+ warnings.append(f"stream {stream_number} FlateDecode failed: {exc}")
60
+ cursor = end + len(b"endstream")
61
+ continue
62
+ elif b"/Filter" in dictionary:
63
+ warnings.append(f"stream {stream_number} uses an unsupported PDF filter")
64
+ cursor = end + len(b"endstream")
65
+ continue
66
+ streams.append(payload)
67
+ cursor = end + len(b"endstream")
68
+ return streams, warnings
69
+
70
+
71
+ def audit_pdf(data: bytes, minimum_pt: float = 5.0) -> dict[str, object]:
72
+ streams, warnings = decoded_streams(data)
73
+ runs: list[TextRun] = []
74
+ for stream_index, stream in enumerate(streams, 1):
75
+ for match in TF_OPERATOR.finditer(stream):
76
+ try:
77
+ font = match.group(1).decode("ascii", errors="replace")
78
+ size = float(match.group(2))
79
+ except ValueError:
80
+ continue
81
+ if size > 0:
82
+ runs.append(TextRun(stream=stream_index, font=font, size_pt=size))
83
+ below = [run for run in runs if run.size_pt < minimum_pt]
84
+ return {
85
+ "auditable": bool(runs),
86
+ "minimum_required_pt": minimum_pt,
87
+ "minimum_found_pt": min((run.size_pt for run in runs), default=None),
88
+ "text_run_count": len(runs),
89
+ "below_minimum_count": len(below),
90
+ "below_minimum": [asdict(run) for run in below],
91
+ "warnings": warnings,
92
+ }
93
+
94
+
95
+ def render_text(path: Path, result: dict[str, object]) -> str:
96
+ lines = [
97
+ "Bioresearcher Plot PDF Text Audit",
98
+ f"pdf: {path}",
99
+ f"minimum required: {result['minimum_required_pt']:g} pt",
100
+ ]
101
+ if not result["auditable"]:
102
+ lines.append("verdict: NOT AUDITABLE — no supported Tf text operators were found")
103
+ else:
104
+ lines.extend(
105
+ [
106
+ f"minimum found: {result['minimum_found_pt']:g} pt",
107
+ f"text runs: {result['text_run_count']}",
108
+ f"below minimum: {result['below_minimum_count']}",
109
+ f"verdict: {'FAIL' if result['below_minimum_count'] else 'PASS'}",
110
+ ]
111
+ )
112
+ for run in result["below_minimum"]:
113
+ lines.append(f" - stream {run['stream']}: /{run['font']} {run['size_pt']:g} Tf")
114
+ for warning in result["warnings"]:
115
+ lines.append(f"warning: {warning}")
116
+ lines.append("note: Tf scanning does not replace final-size visual inspection or account for every PDF transform")
117
+ return "\n".join(lines)
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ parser = argparse.ArgumentParser(description=__doc__)
122
+ parser.add_argument("pdf", type=Path, help="exported PDF figure")
123
+ parser.add_argument("--min-pt", type=float, default=5.0, help="minimum allowed Tf font size in points")
124
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
125
+ return parser
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> int:
129
+ args = build_parser().parse_args(argv)
130
+ if args.min_pt <= 0:
131
+ print("error: --min-pt must be positive", file=sys.stderr)
132
+ return 2
133
+ try:
134
+ data = args.pdf.read_bytes()
135
+ except OSError as exc:
136
+ print(f"error: {exc}", file=sys.stderr)
137
+ return 2
138
+ if not data.startswith(b"%PDF-"):
139
+ print(f"error: not a PDF file: {args.pdf}", file=sys.stderr)
140
+ return 2
141
+ result = audit_pdf(data, minimum_pt=args.min_pt)
142
+ if args.json:
143
+ print(json.dumps({"pdf": str(args.pdf), **result}, indent=2, ensure_ascii=False))
144
+ else:
145
+ print(render_text(args.pdf, result))
146
+ if not result["auditable"]:
147
+ return 2
148
+ return 1 if result["below_minimum_count"] else 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """Universal Scientific Plotting Helpers for bioresearcher-plot-making.
3
+
4
+ Provides publication-grade layout builders, dynamic font-metric calculations,
5
+ tight alpha-mask cropping for 3D renders, font-aware text wrapping, and
6
+ integrated QA gate verification.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+ import matplotlib as mpl
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ from PIL import Image
15
+
16
+ # Ensure bundled audit scripts are discoverable
17
+ SCRIPTS_DIR = Path(__file__).resolve().parent
18
+ if str(SCRIPTS_DIR) not in sys.path:
19
+ sys.path.insert(0, str(SCRIPTS_DIR))
20
+
21
+ try:
22
+ from audit_panel_alignment import require_matplotlib_panel_alignment
23
+ except ImportError:
24
+ require_matplotlib_panel_alignment = None
25
+
26
+ MM = 1 / 25.4 # Millimeter to inch conversion factor
27
+
28
+
29
+ def make_figure(width_mm=180, height_mm=120):
30
+ """Initialize a publication figure sized in millimeters with Nature typography."""
31
+ mpl.rcParams.update({
32
+ "font.family": "sans-serif",
33
+ "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
34
+ "font.size": 7,
35
+ "axes.labelsize": 7,
36
+ "xtick.labelsize": 6,
37
+ "ytick.labelsize": 6,
38
+ "svg.fonttype": "none",
39
+ "pdf.fonttype": 42,
40
+ "axes.linewidth": 0.7,
41
+ "lines.linewidth": 1.0,
42
+ })
43
+ return plt.figure(figsize=(width_mm * MM, height_mm * MM))
44
+
45
+
46
+ def load_render_cropped(png_path, pad=(12, 12, 12, 12), return_crop=False):
47
+ """Load transparent 3D render and crop tightly to non-zero alpha bounding box.
48
+
49
+ Parameters
50
+ ----------
51
+ png_path : str or Path
52
+ Path to input transparent PNG.
53
+ pad : int or tuple of (left, top, right, bottom)
54
+ Pixel padding around the alpha bounding box.
55
+ return_crop : bool
56
+ If True, returns (cropped_image, (x0, y0, x1, y1)).
57
+ """
58
+ if isinstance(pad, (int, float)):
59
+ pad_l = pad_t = pad_r = pad_b = int(pad)
60
+ else:
61
+ pad_l, pad_t, pad_r, pad_b = (int(v) for v in pad)
62
+
63
+ arr = np.array(Image.open(png_path))
64
+ if arr.ndim < 3 or arr.shape[2] < 4:
65
+ return (arr, (0, 0, arr.shape[1], arr.shape[0])) if return_crop else arr
66
+
67
+ alpha = arr[..., 3]
68
+ ys, xs = np.where(alpha > 10)
69
+ if len(xs) == 0 or len(ys) == 0:
70
+ return (arr, (0, 0, arr.shape[1], arr.shape[0])) if return_crop else arr
71
+
72
+ x0 = max(int(xs.min()) - pad_l, 0)
73
+ x1 = min(int(xs.max()) + pad_r, arr.shape[1])
74
+ y0 = max(int(ys.min()) - pad_t, 0)
75
+ y1 = min(int(ys.max()) + pad_b, arr.shape[0])
76
+
77
+ cropped = arr[y0:y1, x0:x1]
78
+ return (cropped, (x0, y0, x1, y1)) if return_crop else cropped
79
+
80
+
81
+ def text_width(ax, text_artist):
82
+ """Calculate renderer advance width in data coordinates."""
83
+ fig = ax.figure
84
+ renderer = fig.canvas.get_renderer()
85
+ weight = text_artist.get_fontweight()
86
+ is_bold = weight == "bold" or (isinstance(weight, (int, float)) and weight > 500)
87
+ w_adv, _, _ = renderer.get_text_width_height_descent(
88
+ text_artist.get_text(), text_artist.get_fontproperties(), is_bold
89
+ )
90
+ inv = ax.transData.inverted()
91
+ (x0_adv, _), (x1_adv, _) = inv.transform([(0, 0), (w_adv, 0)])
92
+ return x1_adv - x0_adv
93
+
94
+
95
+ def right_edge(ax, text_artist):
96
+ """Calculate robust text right boundary taking max of ink bbox and advance width."""
97
+ fig = ax.figure
98
+ renderer = fig.canvas.get_renderer()
99
+ bb = text_artist.get_window_extent(renderer=renderer)
100
+ inv = ax.transData.inverted()
101
+ (_, _), (x_ink, _) = inv.transform([(bb.x0, bb.y0), (bb.x1, bb.y1)])
102
+ x_adv = text_artist.get_position()[0] + text_width(ax, text_artist)
103
+ return max(x_ink, x_adv)
104
+
105
+
106
+ def tag_right(ax, text, y_data, size=5.5, color="#5A5A5A"):
107
+ """Pin provenance text flush to the panel right edge using blended transform."""
108
+ import matplotlib.transforms as mtransforms
109
+ tr = mtransforms.blended_transform_factory(ax.transAxes, ax.transData)
110
+ return ax.text(
111
+ 1.0, y_data, text, transform=tr, fontsize=size,
112
+ color=color, ha="right", va="center", style="italic", zorder=6
113
+ )
114
+
115
+
116
+ def wrap_cell_text(text, max_chars_per_line=30):
117
+ """Wrap string into multi-line list preserving word boundaries."""
118
+ words = text.split()
119
+ if not words:
120
+ return [""]
121
+ lines = []
122
+ current = []
123
+ curr_len = 0
124
+ for w in words:
125
+ if curr_len + len(w) + (1 if current else 0) > max_chars_per_line:
126
+ lines.append(" ".join(current))
127
+ current = [w]
128
+ curr_len = len(w)
129
+ else:
130
+ current.append(w)
131
+ curr_len += len(w) + (1 if len(current) > 1 else 0)
132
+ if current:
133
+ lines.append(" ".join(current))
134
+ return lines
135
+
136
+
137
+ def export_publication_figure(
138
+ fig,
139
+ base_name,
140
+ panel_ids=None,
141
+ row_groups=None,
142
+ column_groups=None,
143
+ exemptions=None,
144
+ exclude_axes=None,
145
+ axes=None,
146
+ tolerance_pt=1.5,
147
+ dpi=300,
148
+ **kwargs,
149
+ ):
150
+ """Execute panel alignment gate and export vector PDF, SVG, and raster PNG."""
151
+ Path(base_name).parent.mkdir(parents=True, exist_ok=True)
152
+ if require_matplotlib_panel_alignment is not None:
153
+ opts = dict(
154
+ tolerance_pt=tolerance_pt,
155
+ strict=True,
156
+ panel_ids=panel_ids,
157
+ row_groups=row_groups,
158
+ column_groups=column_groups,
159
+ **kwargs,
160
+ )
161
+ if exemptions is not None:
162
+ opts["exemptions"] = exemptions
163
+ if exclude_axes is not None:
164
+ opts["exclude_axes"] = exclude_axes
165
+ if axes is not None:
166
+ opts["axes"] = axes
167
+ require_matplotlib_panel_alignment(
168
+ fig,
169
+ json_out=f"{base_name}.alignment.json",
170
+ overlay_svg=f"{base_name}.alignment.svg",
171
+ **opts,
172
+ )
173
+
174
+ fig.savefig(f"{base_name}.pdf", bbox_inches="tight")
175
+ fig.savefig(f"{base_name}.svg", bbox_inches="tight")
176
+ fig.savefig(f"{base_name}.png", dpi=dpi, bbox_inches="tight")
177
+ print(f"[bioresearcher-plot-making] Exported {base_name}.{{pdf,svg,png}}")
@@ -0,0 +1,223 @@
1
+ ---
2
+ name: bioresearcher-pubmed-weekly
3
+ description: "Downloads the past week's PubMed daily update XML.gz files from ftp.ncbi.nlm.nih.gov/pubmed/updatefiles/, streams-parses interleaved PubmedArticle and DeleteCitation records, and produces one combined Excel workbook plus a summary JSON. Use for weekly PubMed/NLM literature tracking, NCBI updatefiles retrieval, pubmedNNnNNNN.xml.gz parsing, PMID/DOI/journal/author extraction, deleted-PMID lists, or building a weekly biomedical literature table."
4
+ license: Apache-2.0
5
+ compatibility: "Unix-like shells (Linux, macOS, Git Bash) and Windows cmd.exe; requires uv + openpyxl and NCBI FTP access"
6
+ metadata:
7
+ version: "1.0.0"
8
+ source: "opencode-bioresearcher-plugin@1.7.2"
9
+ allowed-tools: Bash Read Glob
10
+ ---
11
+
12
+ # PubMed Weekly Update Download and Parse
13
+
14
+ This skill downloads the past week's (Monday-Sunday) PubMed daily update `xml.gz` files from `ftp://ftp.ncbi.nlm.nih.gov/pubmed/updatefiles/`, parses them with the bundled streaming scripts, and produces ONE combined Excel workbook (`combined.xlsx`) plus a summary JSON.
15
+
16
+ ## Workflow Overview
17
+
18
+ 1. **Python environment check**: ensure uv is available (install via the `bioresearcher-python-setup-uv` skill if needed)
19
+ 2. **Date-range calculation**: compute the past week's Monday-Sunday range
20
+ 3. **FTP listing + filtering**: fetch available `xml.gz` updatefiles and keep those modified within the week
21
+ 4. **Download with retry + resume**: download each filtered file (3 attempts, 2s delay, `.part` resume)
22
+ 5. **Parse + combine via scripts**: parse all downloaded files and produce one Excel workbook
23
+ 6. **Report**: verify outputs and summarize to the user
24
+
25
+ ## Prerequisites
26
+
27
+ - Internet connection and access to the NCBI FTP server
28
+ - uv package manager (if missing, load the `bioresearcher-python-setup-uv` skill and follow it EXACTLY, then return here)
29
+ - openpyxl is provided at runtime via `uv run --with openpyxl`
30
+
31
+ ## Script Usage
32
+
33
+ Replace `<skill_dir>` with the full path to this skill's directory (the scripts live in `<skill_dir>/scripts/`). Run all commands from the working directory where downloads should land.
34
+
35
+ **For Unix-like shells (Git Bash / macOS / Linux):**
36
+ ```bash
37
+ uv run --with openpyxl python <skill_dir>/scripts/pubmed_weekly.py <command> [args...]
38
+ ```
39
+
40
+ **For Windows cmd.exe:**
41
+ ```bash
42
+ uv.exe run --with openpyxl python <skill_dir>\scripts\pubmed_weekly.py <command> [args...]
43
+ ```
44
+
45
+ ## Steps
46
+
47
+ Follow these steps EXACTLY as described.
48
+
49
+ ### Step 1: Check uv Prerequisite
50
+
51
+ ```bash
52
+ if [ -f "uv" ] || [ -f "uv.exe" ]; then
53
+ echo "uv already installed"
54
+ else
55
+ echo "uv not found, setting up..."
56
+ fi
57
+ ```
58
+
59
+ If uv is not installed, load the `bioresearcher-python-setup-uv` skill and follow all its steps EXACTLY, then continue with Step 2 below.
60
+
61
+ ### Step 2: Calculate Week Date Range
62
+
63
+ Determine the date range for the past week (Monday through Sunday).
64
+
65
+ **For Unix-like shells:**
66
+ ```bash
67
+ uv run --with openpyxl python <skill_dir>/scripts/pubmed_weekly.py calculate_week
68
+ ```
69
+
70
+ **For Windows cmd.exe:**
71
+ ```bash
72
+ uv.exe run --with openpyxl python <skill_dir>\scripts\pubmed_weekly.py calculate_week
73
+ ```
74
+
75
+ This outputs the week folder name in format `YYYYMMDD-YYYYMMDD`, e.g.:
76
+
77
+ ```
78
+ 20250217-20250223
79
+ ```
80
+
81
+ ### Step 3: Fetch FTP File List
82
+
83
+ Fetch the list of daily update `xml.gz` files from the NCBI FTP server.
84
+
85
+ **For Unix-like shells:**
86
+ ```bash
87
+ uv run --with openpyxl python <skill_dir>/scripts/pubmed_weekly.py fetch_files
88
+ ```
89
+
90
+ **For Windows cmd.exe:**
91
+ ```bash
92
+ uv.exe run --with openpyxl python <skill_dir>\scripts\pubmed_weekly.py fetch_files
93
+ ```
94
+
95
+ **Expected output (space-separated filenames):**
96
+ ```
97
+ pubmed24n1234.xml.gz pubmed24n1235.xml.gz pubmed24n1236.xml.gz
98
+ ```
99
+
100
+ ### Step 4: Filter Files for Past Week
101
+
102
+ Filter the file list to those modified within the week (PubMed filenames do not encode dates, so FTP modification times are used).
103
+
104
+ **For Unix-like shells:**
105
+ ```bash
106
+ uv run --with openpyxl python <skill_dir>/scripts/pubmed_weekly.py filter_files "<WEEK>" "<FILE_LIST>"
107
+ ```
108
+
109
+ **For Windows cmd.exe:**
110
+ ```bash
111
+ uv.exe run --with openpyxl python <skill_dir>\scripts\pubmed_weekly.py filter_files "<WEEK>" "<FILE_LIST>"
112
+ ```
113
+
114
+ Where `<WEEK>` is the week folder name (e.g., `20250217-20250223`) and `<FILE_LIST>` is the Step 3 output (quote it). Returns the space-separated filtered list.
115
+
116
+ ### Step 5: Download Files with Retry and Resume
117
+
118
+ Download each filtered file into `.download/pubmed-daily/<WEEK>/`.
119
+
120
+ **For Unix-like shells:**
121
+ ```bash
122
+ for file in <FILE_LIST>; do
123
+ uv run --with openpyxl python <skill_dir>/scripts/pubmed_weekly.py download_file "<WEEK>" "$file"
124
+ done
125
+ ```
126
+
127
+ **For Windows cmd.exe:**
128
+ ```bash
129
+ for %f in (<FILE_LIST>) do uv.exe run --with openpyxl python <skill_dir>\scripts\pubmed_weekly.py download_file "<WEEK>" %f
130
+ ```
131
+
132
+ **Download behavior:**
133
+ - Downloads one file at a time to `<filename>.part`, renamed on success
134
+ - Retries up to 3 times per file with 2-second delays
135
+ - Resumes interrupted `.part` downloads; skips already-completed files
136
+ - If a download fails after 3 retries, ask the user: "Abort remaining downloads?" ("Yes" / "No"). "Yes" stops and reports; "No" skips the failed file and continues
137
+
138
+ ### Step 6: Parse and Combine into One Excel
139
+
140
+ Parse every downloaded `xml.gz` in the week directory and write ONE combined workbook `combined.xlsx` plus `summary.json` (recommended; done in a single command):
141
+
142
+ **For Unix-like shells:**
143
+ ```bash
144
+ uv run --with openpyxl python <skill_dir>/scripts/pubmed_weekly.py combine "<WEEK>"
145
+ ```
146
+
147
+ **For Windows cmd.exe:**
148
+ ```bash
149
+ uv.exe run --with openpyxl python <skill_dir>\scripts\pubmed_weekly.py combine "<WEEK>"
150
+ ```
151
+
152
+ **Output location:**
153
+ ```
154
+ .download/pubmed-daily/<WEEK>/combined.xlsx
155
+ .download/pubmed-daily/<WEEK>/summary.json
156
+ ```
157
+
158
+ Alternatively, invoke the parser directly (Unix; on Windows cmd.exe list files explicitly since cmd.exe does not expand `*`):
159
+ ```bash
160
+ uv run --with openpyxl python <skill_dir>/scripts/parse_updatefiles.py \
161
+ .download/pubmed-daily/<WEEK>/pubmed24n1234.xml.gz \
162
+ .download/pubmed-daily/<WEEK>/pubmed24n1235.xml.gz \
163
+ -o .download/pubmed-daily/<WEEK>/combined.xlsx \
164
+ --summary-json .download/pubmed-daily/<WEEK>/summary.json
165
+ ```
166
+
167
+ The parser accepts plain `.xml` as well as `.xml.gz` (gzip-transparent) and any number of input files; ordering follows the given file order, document order within each file.
168
+
169
+ ### Step 7: Verify and Report
170
+
171
+ ```bash
172
+ ls -lh .download/pubmed-daily/<WEEK>/
173
+ ```
174
+
175
+ Report to the user: week range, files found/downloaded/failed, download location, article count and deleted-PMID count in `combined.xlsx`, and the `combined.xlsx` / `summary.json` locations.
176
+
177
+ ## Frozen Output Schema
178
+
179
+ `combined.xlsx` (openpyxl workbook, write_only; schema is FROZEN — do not change):
180
+
181
+ **Sheet 1: `PubMed Articles`** — columns in EXACT order:
182
+
183
+ | # | Column | Source |
184
+ |---|--------|--------|
185
+ | 1 | `PMID` | `MedlineCitation/PMID` text |
186
+ | 2 | `DOI` | `PubmedData/ArticleIdList/ArticleId[@IdType='doi']` if present, else empty |
187
+ | 3 | `Title` | `Article/ArticleTitle` (inline markup flattened) |
188
+ | 4 | `Journal` | `Article/Journal/Title` |
189
+ | 5 | `ISSN` | first `Article/Journal/ISSN` |
190
+ | 6 | `PubDate` | `Journal/JournalIssue/PubDate` normalized: Year + Month (name or number) → `YYYY-MM`; Year only → `YYYY`; no Year → empty |
191
+ | 7 | `FirstAuthor` | first `Article/AuthorList/Author` as `LastName Initials` (e.g., `Smith J`) |
192
+ | 8 | `LastAuthor` | last `Article/AuthorList/Author` in the same format |
193
+ | 9 | `PublicationTypes` | `Article/PublicationTypeList/PublicationType` values joined with `;` |
194
+
195
+ **Sheet 2: `Deleted PMIDs`** — single column:
196
+
197
+ | # | Column | Source |
198
+ |---|--------|--------|
199
+ | 1 | `PMID` | each `DeleteCitation/PMID` text |
200
+
201
+ **`summary.json`** (written with `--summary-json` / by `combine`):
202
+
203
+ ```json
204
+ {
205
+ "source_files": ["pubmed24n1234.xml.gz"],
206
+ "article_count": 1234,
207
+ "deleted_pmids": ["99999999"],
208
+ "first_rows": [{"PMID": "...", "DOI": "...", "Title": "...", "Journal": "...", "ISSN": "...", "PubDate": "...", "FirstAuthor": "...", "LastAuthor": "...", "PublicationTypes": "..."}]
209
+ }
210
+ ```
211
+
212
+ `first_rows` contains up to the first 3 article rows as objects keyed by the column names above.
213
+
214
+ ## Notes
215
+
216
+ - updatefiles interleave `<PubmedArticle>` and `<DeleteCitation>` records — BOTH are handled by the parser
217
+ - Parsing is memory-bounded streaming: `xml.etree.iterparse` with element clearing plus an openpyxl `write_only` workbook
218
+ - No plugin tool dependencies: stdlib `urllib` for FTP, openpyxl for Excel, nothing else
219
+ - Output ordering is deterministic: input file order (sorted within `combine`), document order within each file
220
+ - All downloads and outputs live under `.download/pubmed-daily/<WEEK>/` in the current working directory
221
+ - Only `.xml.gz` updatefiles are downloaded; downloads are sequential with retry + resume
222
+ - The FTP server path is `ftp://ftp.ncbi.nlm.nih.gov/pubmed/updatefiles/`
223
+ - Windows with Git Bash: follow Unix-like shell instructions; Windows cmd.exe: use `uv.exe ...` variants