"""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

, 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

 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)30 -> 30x T ; (A)30 -> 30x A  (last base char is the repeat unit)
    s = re.sub(r"\((d?[ACGT])\)\s*(\d+)", 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 

: {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

")


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 

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

 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 
 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 

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), )