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,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Streaming parser for PubMed updatefiles XML / XML.gz into one Excel workbook.
|
|
3
|
+
|
|
4
|
+
Reads one or more NCBI PubMed updatefiles (``pubmedNNnNNNN.xml`` or
|
|
5
|
+
``.xml.gz``) and produces a single ``.xlsx`` workbook:
|
|
6
|
+
|
|
7
|
+
- Sheet ``PubMed Articles``: columns PMID, DOI, Title, Journal, ISSN, PubDate,
|
|
8
|
+
FirstAuthor, LastAuthor, PublicationTypes (exact frozen order).
|
|
9
|
+
- Sheet ``Deleted PMIDs``: single column PMID.
|
|
10
|
+
|
|
11
|
+
Updatefiles interleave ``<PubmedArticle>`` and ``<DeleteCitation>`` records;
|
|
12
|
+
both are handled. Parsing is memory-bounded: ``xml.etree.iterparse`` with
|
|
13
|
+
element clearing plus an ``openpyxl`` write_only workbook. Output ordering is
|
|
14
|
+
deterministic: input file order on the command line, document order within
|
|
15
|
+
each file. Depends only on the Python standard library and openpyxl.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import gzip
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import xml.etree.ElementTree as ET
|
|
24
|
+
|
|
25
|
+
from openpyxl import Workbook
|
|
26
|
+
|
|
27
|
+
ARTICLE_SHEET = "PubMed Articles"
|
|
28
|
+
DELETED_SHEET = "Deleted PMIDs"
|
|
29
|
+
|
|
30
|
+
COLUMNS = [
|
|
31
|
+
"PMID",
|
|
32
|
+
"DOI",
|
|
33
|
+
"Title",
|
|
34
|
+
"Journal",
|
|
35
|
+
"ISSN",
|
|
36
|
+
"PubDate",
|
|
37
|
+
"FirstAuthor",
|
|
38
|
+
"LastAuthor",
|
|
39
|
+
"PublicationTypes",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
MONTHS = {
|
|
43
|
+
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
|
|
44
|
+
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
|
|
45
|
+
"january": 1, "february": 2, "march": 3, "april": 4, "june": 6,
|
|
46
|
+
"july": 7, "august": 8, "september": 9, "october": 10,
|
|
47
|
+
"november": 11, "december": 12,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
GZIP_MAGIC = b"\x1f\x8b"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def open_maybe_gzip(path):
|
|
54
|
+
"""Open ``path`` as a gzip stream if it carries the gzip magic bytes."""
|
|
55
|
+
with open(path, "rb") as probe:
|
|
56
|
+
magic = probe.read(2)
|
|
57
|
+
if magic == GZIP_MAGIC:
|
|
58
|
+
return gzip.open(path, "rb")
|
|
59
|
+
return open(path, "rb")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def join_text(elem):
|
|
63
|
+
"""Return concatenated (markup-flattened) stripped text of ``elem``."""
|
|
64
|
+
if elem is None:
|
|
65
|
+
return ""
|
|
66
|
+
return "".join(elem.itertext()).strip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_month(value):
|
|
70
|
+
"""Map a Month element value (name or number) to a 1-12 int, or None."""
|
|
71
|
+
if not value:
|
|
72
|
+
return None
|
|
73
|
+
text = value.strip()
|
|
74
|
+
if text.isdigit():
|
|
75
|
+
number = int(text)
|
|
76
|
+
if 1 <= number <= 12:
|
|
77
|
+
return number
|
|
78
|
+
return None
|
|
79
|
+
return MONTHS.get(text.lower())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_pubdate(pubdate):
|
|
83
|
+
"""Normalize JournalIssue/PubDate to ``YYYY-MM`` or ``YYYY`` (or ``""``).
|
|
84
|
+
|
|
85
|
+
Accepts Year plus Month given as a name (``Jan`` / ``January``) or a
|
|
86
|
+
number (``1`` / ``01``). Day is consumed by PubMed but intentionally not
|
|
87
|
+
emitted. Records carrying only MedlineDate (no Year) yield ``""``.
|
|
88
|
+
"""
|
|
89
|
+
if pubdate is None:
|
|
90
|
+
return ""
|
|
91
|
+
year_text = join_text(pubdate.find("Year"))
|
|
92
|
+
if not year_text:
|
|
93
|
+
return ""
|
|
94
|
+
try:
|
|
95
|
+
year = f"{int(year_text):04d}"
|
|
96
|
+
except ValueError:
|
|
97
|
+
return ""
|
|
98
|
+
month = normalize_month(join_text(pubdate.find("Month")))
|
|
99
|
+
if month is not None:
|
|
100
|
+
return f"{year}-{month:02d}"
|
|
101
|
+
return year
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def format_author(author):
|
|
105
|
+
"""Format an Author element as ``LastName Initials`` (LastInitials)."""
|
|
106
|
+
if author is None:
|
|
107
|
+
return ""
|
|
108
|
+
last = join_text(author.find("LastName"))
|
|
109
|
+
if not last:
|
|
110
|
+
return join_text(author.find("CollectiveName"))
|
|
111
|
+
initials = join_text(author.find("Initials"))
|
|
112
|
+
if initials:
|
|
113
|
+
return f"{last} {initials}"
|
|
114
|
+
return last
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_article(elem):
|
|
118
|
+
"""Extract one row (dict keyed by COLUMNS) from a PubmedArticle element."""
|
|
119
|
+
article = elem.find("./MedlineCitation/Article")
|
|
120
|
+
journal = article.find("Journal") if article is not None else None
|
|
121
|
+
authors = (
|
|
122
|
+
article.findall("./AuthorList/Author") if article is not None else []
|
|
123
|
+
)
|
|
124
|
+
pub_types = (
|
|
125
|
+
[
|
|
126
|
+
join_text(pt)
|
|
127
|
+
for pt in article.findall("./PublicationTypeList/PublicationType")
|
|
128
|
+
]
|
|
129
|
+
if article is not None
|
|
130
|
+
else []
|
|
131
|
+
)
|
|
132
|
+
doi = elem.find("./PubmedData/ArticleIdList/ArticleId[@IdType='doi']")
|
|
133
|
+
pub_date_elem = (
|
|
134
|
+
journal.find("./JournalIssue/PubDate") if journal is not None else None
|
|
135
|
+
)
|
|
136
|
+
return {
|
|
137
|
+
"PMID": join_text(elem.find("./MedlineCitation/PMID")),
|
|
138
|
+
"DOI": join_text(doi),
|
|
139
|
+
"Title": join_text(article.find("ArticleTitle"))
|
|
140
|
+
if article is not None
|
|
141
|
+
else "",
|
|
142
|
+
"Journal": join_text(journal.find("Title"))
|
|
143
|
+
if journal is not None
|
|
144
|
+
else "",
|
|
145
|
+
"ISSN": join_text(journal.find("ISSN")) if journal is not None else "",
|
|
146
|
+
"PubDate": normalize_pubdate(pub_date_elem),
|
|
147
|
+
"FirstAuthor": format_author(authors[0]) if authors else "",
|
|
148
|
+
"LastAuthor": format_author(authors[-1]) if authors else "",
|
|
149
|
+
"PublicationTypes": ";".join(pt for pt in pub_types if pt),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def iter_records(stream):
|
|
154
|
+
"""Yield (tag, element) for top-level records, clearing processed memory.
|
|
155
|
+
|
|
156
|
+
Uses ElementTree iterparse; after each yielded record is consumed, the
|
|
157
|
+
element is cleared and detached from the root so memory stays bounded.
|
|
158
|
+
"""
|
|
159
|
+
root = None
|
|
160
|
+
for event, elem in ET.iterparse(stream, events=("start", "end")):
|
|
161
|
+
if event == "start":
|
|
162
|
+
if root is None:
|
|
163
|
+
root = elem
|
|
164
|
+
continue
|
|
165
|
+
if elem.tag in ("PubmedArticle", "DeleteCitation"):
|
|
166
|
+
yield elem.tag, elem
|
|
167
|
+
elem.clear()
|
|
168
|
+
if root is not None:
|
|
169
|
+
try:
|
|
170
|
+
root.remove(elem)
|
|
171
|
+
except ValueError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_files(files, output, summary_json=None):
|
|
176
|
+
"""Parse updatefiles into one workbook; return and optionally dump summary.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
files: Input ``.xml`` / ``.xml.gz`` paths (order preserved in output).
|
|
180
|
+
output: Destination ``.xlsx`` path.
|
|
181
|
+
summary_json: Optional path for the JSON summary.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dict: {source_files, article_count, deleted_pmids, first_rows}.
|
|
185
|
+
"""
|
|
186
|
+
rows = []
|
|
187
|
+
deleted_pmids = []
|
|
188
|
+
|
|
189
|
+
for path in files:
|
|
190
|
+
with open_maybe_gzip(path) as stream:
|
|
191
|
+
for tag, elem in iter_records(stream):
|
|
192
|
+
if tag == "PubmedArticle":
|
|
193
|
+
rows.append(parse_article(elem))
|
|
194
|
+
else:
|
|
195
|
+
deleted_pmids.extend(
|
|
196
|
+
join_text(p) for p in elem.findall("./PMID")
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
workbook = Workbook(write_only=True)
|
|
200
|
+
|
|
201
|
+
articles_sheet = workbook.create_sheet(ARTICLE_SHEET)
|
|
202
|
+
articles_sheet.append(COLUMNS)
|
|
203
|
+
for row in rows:
|
|
204
|
+
articles_sheet.append([row[column] for column in COLUMNS])
|
|
205
|
+
|
|
206
|
+
deleted_sheet = workbook.create_sheet(DELETED_SHEET)
|
|
207
|
+
deleted_sheet.append(["PMID"])
|
|
208
|
+
for pmid in deleted_pmids:
|
|
209
|
+
deleted_sheet.append([pmid])
|
|
210
|
+
|
|
211
|
+
workbook.save(output)
|
|
212
|
+
|
|
213
|
+
summary = {
|
|
214
|
+
"source_files": [os.path.basename(path) for path in files],
|
|
215
|
+
"article_count": len(rows),
|
|
216
|
+
"deleted_pmids": deleted_pmids,
|
|
217
|
+
"first_rows": rows[:3],
|
|
218
|
+
}
|
|
219
|
+
if summary_json:
|
|
220
|
+
with open(summary_json, "w", encoding="utf-8") as handle:
|
|
221
|
+
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
|
222
|
+
return summary
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv=None):
|
|
226
|
+
"""Command-line entry point."""
|
|
227
|
+
parser = argparse.ArgumentParser(
|
|
228
|
+
description=(
|
|
229
|
+
"Streaming parser for PubMed updatefiles XML(.gz) into one "
|
|
230
|
+
"Excel workbook (PubMed Articles + Deleted PMIDs sheets)."
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"files",
|
|
235
|
+
nargs="+",
|
|
236
|
+
help="Input PubMed updatefiles (.xml or .xml.gz), order preserved",
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"-o",
|
|
240
|
+
"--output",
|
|
241
|
+
required=True,
|
|
242
|
+
help="Output .xlsx path (e.g. combined.xlsx)",
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"--summary-json",
|
|
246
|
+
dest="summary_json",
|
|
247
|
+
default=None,
|
|
248
|
+
help="Optional path to write the JSON summary",
|
|
249
|
+
)
|
|
250
|
+
args = parser.parse_args(argv)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
summary = parse_files(args.files, args.output, args.summary_json)
|
|
254
|
+
except FileNotFoundError as exc:
|
|
255
|
+
print(f"Error: input file not found: {exc}", file=sys.stderr)
|
|
256
|
+
return 1
|
|
257
|
+
|
|
258
|
+
print(
|
|
259
|
+
"Parsed {} article(s) and {} deleted PMID(s) from {} file(s)".format(
|
|
260
|
+
summary["article_count"],
|
|
261
|
+
len(summary["deleted_pmids"]),
|
|
262
|
+
len(summary["source_files"]),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
print(f"Output: {args.output}")
|
|
266
|
+
if args.summary_json:
|
|
267
|
+
print(f"Summary: {args.summary_json}")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
sys.exit(main())
|