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.
- package/LICENSE +201 -0
- package/README.md +103 -0
- package/agents/bioresearcher-dr-worker.md +54 -0
- package/connector-meta.json +23 -0
- package/index.js +77 -0
- package/loader.js +3 -0
- package/package.json +42 -0
- package/skill-bundle.json +12 -0
- package/skills/bioresearcher-deep-research/SKILL.md +330 -0
- package/skills/bioresearcher-deep-research/references/analysis-methods.md +90 -0
- package/skills/bioresearcher-deep-research/references/article-literature.md +89 -0
- package/skills/bioresearcher-deep-research/references/best-practices.md +102 -0
- package/skills/bioresearcher-deep-research/references/citations.md +146 -0
- package/skills/bioresearcher-deep-research/references/clinical-trials.md +87 -0
- package/skills/bioresearcher-deep-research/references/diseases.md +94 -0
- package/skills/bioresearcher-deep-research/references/drugs.md +88 -0
- package/skills/bioresearcher-deep-research/references/ensembl-pdb.md +134 -0
- package/skills/bioresearcher-deep-research/references/functional-genomics.md +118 -0
- package/skills/bioresearcher-deep-research/references/genes.md +93 -0
- package/skills/bioresearcher-deep-research/references/optional-analysis.md +108 -0
- package/skills/bioresearcher-deep-research/references/patents.md +92 -0
- package/skills/bioresearcher-deep-research/references/rate-limiting-auth.md +95 -0
- package/skills/bioresearcher-deep-research/references/report-template.md +117 -0
- package/skills/bioresearcher-deep-research/references/tool-selection.md +142 -0
- package/skills/bioresearcher-deep-research/references/utility-config.md +116 -0
- package/skills/bioresearcher-deep-research/references/variants.md +109 -0
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +110 -0
- package/skills/bioresearcher-deep-research/scripts/markdown-to-html.py +86 -0
- package/skills/bioresearcher-plot-making/SKILL.md +97 -0
- package/skills/bioresearcher-plot-making/references/literature-search-method-summary.md +163 -0
- package/skills/bioresearcher-plot-making/references/qa-gates-and-gotchas.md +156 -0
- package/skills/bioresearcher-plot-making/references/structural-biology-binder-visualization.md +206 -0
- package/skills/bioresearcher-plot-making/scripts/audit_figure_collisions.py +742 -0
- package/skills/bioresearcher-plot-making/scripts/audit_panel_alignment.py +935 -0
- package/skills/bioresearcher-plot-making/scripts/audit_pdf_text.py +152 -0
- package/skills/bioresearcher-plot-making/scripts/plot_helpers.py +177 -0
- package/skills/bioresearcher-pubmed-weekly/SKILL.md +223 -0
- package/skills/bioresearcher-pubmed-weekly/scripts/parse_updatefiles.py +272 -0
- package/skills/bioresearcher-pubmed-weekly/scripts/pubmed_weekly.py +493 -0
- 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
|