File size: 7,493 Bytes
ff34739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""Parse the scg_lib_structs ``10xChromium3.html`` into raw sequences.

Extraction only — no interpretation/assembly (that is ``builder.py``). We read the tagged
oligo sequences from the "Adapter and primer sequences" section (selecting the v3+ variant where
the page shows v2-vs-v3+ alternatives), the verbatim v3/v3.1/v4 final-library strand lines, and a
few sequencing hints. Placeholder text becomes tokens: ``[16-bp cell barcode]`` -> ``[CELL_BARCODE:16]``.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path

from bs4 import BeautifulSoup

__all__ = ["ParsedProtocol", "parse_protocol"]

# (key, label prefix in the <p>, layout: "inline" | "pre", kind)
_OLIGO_SPECS: list[tuple[str, str, str, str]] = [
    ("beads_oligo_dt", "Beads-oligo-dT", "pre", "assembled"),
    ("tso", "Template Switching Oligo", "inline", "assembled"),
    ("cdna_forward_primer", "cDNA Forward primer", "inline", "single"),
    ("cdna_reverse_primer", "cDNA Reverse primer", "pre", "single"),
    ("truseq_read1_primer", "Illumina TruSeq Read 1 primer", "inline", "assembled"),
    ("truseq_read2_primer", "Illumina TruSeq Read 2 primer", "inline", "single"),
    ("truseq_adapter", "Truseq adapter", "pre", "double_stranded"),
    ("library_pcr_primer_1", "Library PCR primer 1", "inline", "assembled"),
    ("library_pcr_primer_2", "Library PCR primer 2", "inline", "assembled"),
    ("sample_index_seq_primer", "Sample index sequencing primer", "inline", "single"),
    ("p5", "Illumina P5 adapter", "inline", "single"),
    ("p7", "Illumina P7 adapter", "inline", "single"),
]

_VARIANT_MARKER = "V3"  # select the "V3, V3.1, V4" line in variant <pre> blocks


@dataclass
class ParsedProtocol:
    source_path: str
    source_html_sha256: str
    oligos: dict[str, object] = field(default_factory=dict)   # key -> str | {"fwd","rev"}
    oligo_kinds: dict[str, str] = field(default_factory=dict)
    final_library: dict[str, str] = field(default_factory=dict)
    sequencing: dict[str, int] = field(default_factory=dict)
    library_generation: list = field(default_factory=list)


def _clean_seq(fragment: str) -> str:
    """HTML sequence fragment -> clean sequence-with-tokens (uppercase ACGTN + [TOKEN:n] + VN)."""
    s = fragment
    # (T)<sub>30</sub> -> 30x T ; (A)<sub>30</sub> -> 30x A  (last base char is the repeat unit)
    s = re.sub(r"\((d?[ACGT])\)\s*<sub>(\d+)</sub>", lambda m: m.group(1)[-1] * int(m.group(2)), s)
    # placeholder text -> tokens
    s = re.sub(r"\[(\d+)-bp cell barcode\]", r"[CELL_BARCODE:\1]", s)
    s = re.sub(r"\[(\d+)-bp UMI\]", r"[UMI:\1]", s)
    s = re.sub(r"\[(\d+)-bp sample index\]", r"[SAMPLE_INDEX:\1]", s)
    # ribo-G notation (TSO): rGrGrG -> GGG
    s = s.replace("rG", "G")
    # drop remaining HTML tags
    s = re.sub(r"<[^>]+>", "", s)
    # drop strand markers, arrows, asterisks, ellipses, and all whitespace
    s = re.sub(r"5'-|-3'|3'-|-5'|\|--|-+>|<-+|\.\.\.|\*|\s+", "", s)
    return s


def _inline_seq(p_tag) -> str:
    frag = p_tag.decode_contents()
    idx = frag.find("5'-")
    if idx < 0:
        raise ValueError(f"no 5'- marker in inline oligo <p>: {frag[:80]!r}")
    return _clean_seq(frag[idx:])


def _pre_lines(pre_tag) -> list[str]:
    return [ln for ln in pre_tag.decode_contents().splitlines() if ln.strip()]


def _variant_line_index(lines: list[str]) -> int:
    for i, ln in enumerate(lines):
        if _VARIANT_MARKER in ln and "5'-" in ln:
            return i
    raise ValueError(f"no {_VARIANT_MARKER!r} variant line found in <pre>")


def _find_labeled_p(soup, label_prefix: str):
    for p in soup.find_all("p"):
        if p.get_text().strip().startswith(label_prefix):
            return p
    raise ValueError(f"no <p> starting with {label_prefix!r}")


def _parse_oligos(soup) -> tuple[dict[str, object], dict[str, str]]:
    oligos: dict[str, object] = {}
    kinds: dict[str, str] = {}
    for key, label, layout, kind in _OLIGO_SPECS:
        p = _find_labeled_p(soup, label)
        kinds[key] = kind
        if layout == "inline":
            oligos[key] = _inline_seq(p)
        else:  # "pre"
            pre = p.find_next_sibling("pre")
            if pre is None:
                raise ValueError(f"no <pre> after label {label!r}")
            lines = _pre_lines(pre)
            i = _variant_line_index(lines)
            fwd_line = lines[i]
            fwd = _clean_seq(fwd_line[fwd_line.find("5'-"):])
            if kind == "double_stranded":
                rev_line = lines[i + 1]
                rev = _clean_seq(rev_line[rev_line.find("3'-"):])
                oligos[key] = {"fwd": fwd, "rev": rev}
            else:
                oligos[key] = fwd
    return oligos, kinds


def _parse_final_library(soup) -> dict[str, str]:
    for h4 in soup.find_all("h4"):
        if "V3, V3.1 & V4" in h4.get_text():
            pre = h4.find_next_sibling("pre")
            if pre is None:
                raise ValueError("no <pre> after V3/V3.1/V4 library heading")
            lines = _pre_lines(pre)
            top = next(ln for ln in lines if "5'-" in ln and "-3'" in ln)
            bot = next(ln for ln in lines if "3'-" in ln and "-5'" in ln)
            strip_tags = lambda ln: re.sub(r"<[^>]+>", "", ln).strip()
            return {
                "source_label": "V3, V3.1 & V4 final library structure",
                "strand_5to3_html": top.strip(),
                "strand_3to5_html": bot.strip(),
                "strand_5to3_text": strip_tags(top),
                "strand_3to5_text": strip_tags(bot),
            }
    raise ValueError("V3/V3.1/V4 final library section not found")


def _parse_library_generation(soup) -> list[dict]:
    """The ordered 'Step-by-step library generation' section: one entry per numbered <h3> step."""
    h2 = next((h for h in soup.find_all("h2") if "step-by-step" in h.get_text().lower()), None)
    if h2 is None:
        return []
    steps: list[dict] = []
    for el in h2.find_all_next():
        if el.name == "h2":
            break  # reached the next section (Library sequencing)
        if el.name == "h3":
            title = re.sub(r"\s+", " ", el.get_text()).strip().rstrip(":")
            m = re.match(r"\((\d+)\)\s*(.*)", title)
            if m:
                steps.append({"step": int(m.group(1)), "title": m.group(2).strip(), "note": None})
            else:
                steps.append({"step": len(steps) + 1, "title": title, "note": None})
    return steps


def _parse_sequencing(soup) -> dict[str, int]:
    text = soup.get_text()
    out: dict[str, int] = {}
    m = re.search(r"(\d+)\s*cycles for V3", text)
    if m:
        out["R1_cycles"] = int(m.group(1))
    m = re.search(r"sequence cDNA,\s*(\d+)\s*cycles", text)
    if m:
        out["R2_cycles_html"] = int(m.group(1))
    m = re.search(r"\[(\d+)-bp sample index\]", text)
    if m:
        out["i7_length"] = int(m.group(1))
    return out


def parse_protocol(html_path: str | Path) -> ParsedProtocol:
    raw = Path(html_path).read_bytes()
    soup = BeautifulSoup(raw.decode("utf-8"), "lxml")
    oligos, kinds = _parse_oligos(soup)
    return ParsedProtocol(
        source_path=str(html_path),
        source_html_sha256=hashlib.sha256(raw).hexdigest(),
        oligos=oligos,
        oligo_kinds=kinds,
        final_library=_parse_final_library(soup),
        sequencing=_parse_sequencing(soup),
        library_generation=_parse_library_generation(soup),
    )