import Foundation /// Lightweight streaming HTML tokenizer + selector tools for inspector-copied /// markup. Not a spec HTML parser — built for "paste from devtools, /// find the repeating rows, pull their data" scraping workflows. /// /// Selector syntax: `tag`, `#id`, `.class`, compounds (`tr.shortcut_navigable`), /// attribute filters (`[data-id]`, `[!class]`, `[rel=tag]`, `[href^=/seller/]`), /// space-separated descendant chains, and a trailing `@attr` to extract an /// attribute value from the matched element. enum HTMLScanner { // MARK: - Public: selector summary struct SummaryData { let entries: [(selector: String, count: Int, sample: String)] let elementCount: Int } static func summarize(html: String) -> SummaryData { var counts: [String: Int] = [:] var samples: [String: String] = [:] struct Entry { let tag: String let key: String? var sampling: Bool var buf: String } var stack: [Entry] = [] var totalElements = 0 func finalize(_ entry: Entry) { guard let key = entry.key, entry.sampling else { return } if samples[key, default: ""].isEmpty { samples[key] = String(entry.buf.prefix(90)) } } tokenize(html, openTag: { tag, attrs, closed in let id = normalizedID(attrs) let classes = splitClasses(attrs["class"]) var key: String? if id != nil || !classes.isEmpty { key = selectorString(tag: tag, id: id, classes: classes) counts[key!, default: 0] += 1 totalElements += 1 } if closed { return } // Keep sampling until a NON-empty sample lands, so a first // occurrence that wraps only an doesn't lose the sample. let sampling = key.map { samples[$0, default: ""].isEmpty } ?? false stack.append(Entry(tag: tag, key: key, sampling: sampling, buf: "")) }, closeTag: { tag in guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } for entry in stack[matchIdx...].reversed() { finalize(entry) } stack.removeSubrange(matchIdx...) }, text: { raw in let collapsed = collapseWhitespace(decodeEntities(raw)) guard !collapsed.isEmpty else { return } for i in stack.indices where stack[i].sampling && stack[i].buf.count < 90 { stack[i].buf += (stack[i].buf.isEmpty ? "" : " ") + collapsed } }) for entry in stack.reversed() { finalize(entry) } let sorted = counts.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key } return SummaryData( entries: sorted.map { (selector: $0.key, count: $0.value, sample: samples[$0.key] ?? "") }, elementCount: totalElements ) } static func formatSummary(_ data: SummaryData, sourceName: String) -> String { var out = "Selector summary of \(sourceName)\n" out += "\(data.elementCount) elements with an id or class, \(data.entries.count) unique selectors.\n" out += "Repeating rows (your data) sort to the top. Next: Tools → Extract Text for Selector…\n\n" for entry in data.entries { out += String(format: "%5d× %@\n", entry.count, entry.selector) if !entry.sample.isEmpty { out += " ↳ \(entry.sample)\n" } } return out } // MARK: - Public: extract text for a selector struct ExtractResult { let matchCount: Int let body: String } /// Returns nil if the selector string can't be parsed. A trailing `@attr` /// prepends that attribute's value as a tab-separated first column. static func extractText(html: String, selector: String, sourceName: String) -> ExtractResult? { guard let compounds = parseSelector(selector) else { return nil } let extractAttr = compounds.last?.extractAttr struct Entry { let tag: String let id: String? let classes: Set let attrs: [String: String] var isCaptureRoot: Bool } var stack: [Entry] = [] var capturing = false var buf = "" var rootAttrValue = "" var records: [String] = [] func selectorMatches() -> Bool { matchChain(compounds, stack: stack.map { ($0.tag, $0.id, $0.classes, $0.attrs) }, scopeStart: 0) } func attrValue(_ attrs: [String: String]) -> String { extractAttr.flatMap { attrs[$0] } ?? "" } func finishRecord() { var record = buf.trimmingCharacters(in: CharacterSet(charactersIn: " \t")) if extractAttr != nil { record = rootAttrValue + "\t" + record } records.append(record) buf = "" rootAttrValue = "" capturing = false } tokenize(html, openTag: { tag, attrs, closed in let entry = Entry(tag: tag, id: normalizedID(attrs), classes: Set(splitClasses(attrs["class"])), attrs: attrs, isCaptureRoot: false) if closed { if !capturing { stack.append(entry) if selectorMatches() { records.append(extractAttr != nil ? attrValue(attrs) + "\t" : "") } stack.removeLast() } return } stack.append(entry) if selectorMatches() { if capturing { // A new match opening mid-capture means the previous row's // close tag was missing (raw source) or rows nest — either // way, finish the old record so rows don't silently merge. finishRecord() if let oldRoot = stack.firstIndex(where: { $0.isCaptureRoot }) { stack[oldRoot].isCaptureRoot = false } } stack[stack.count - 1].isCaptureRoot = true capturing = true buf = "" rootAttrValue = attrValue(attrs) } }, closeTag: { tag in guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } let popped = stack[matchIdx...] stack.removeSubrange(matchIdx...) if popped.contains(where: { $0.isCaptureRoot }) { finishRecord() } else if capturing, tag == "td" || tag == "th", !buf.isEmpty, !buf.hasSuffix("\t") { buf += "\t" // cell boundary → tab, so table rows come out TSV-ish } }, text: { raw in guard capturing else { return } let collapsed = collapseWhitespace(decodeEntities(raw)) guard !collapsed.isEmpty else { return } if !buf.isEmpty && !buf.hasSuffix("\t") { buf += " " } buf += collapsed }) if capturing { finishRecord() } // unclosed capture at EOF var out = "# \(records.count) match\(records.count == 1 ? "" : "es") for `\(selector)` in \(sourceName)\n" out += records.joined(separator: "\n") if !records.isEmpty { out += "\n" } return ExtractResult(matchCount: records.count, body: out) } // MARK: - Public: structured row extraction (scrape profiles) struct RowExtraction { let header: [String] let rows: [[String]] } /// For each element matching rowSelector, capture named fields by selector /// scoped to that row's subtree (the row element itself included, so /// "tr@data-release-id" reads an attribute off the row). First match per /// field wins. Returns nil if any selector fails to parse. static func extractRows(html: String, rowSelector: String, fields: [(name: String, selector: String)]) -> RowExtraction? { guard let rowCompounds = parseSelector(rowSelector) else { return nil } struct FieldSpec { let name: String let compounds: [Compound] let attr: String? } var specs: [FieldSpec] = [] for field in fields { guard let comps = parseSelector(field.selector) else { return nil } specs.append(FieldSpec(name: field.name, compounds: comps, attr: comps.last?.extractAttr)) } typealias StackEl = (tag: String, id: String?, classes: Set, attrs: [String: String]) enum FieldState { case pending case capturing(rootIndex: Int, buf: String) case done(String) } var stack: [StackEl] = [] var inRow = false var rowRootIndex = -1 var fieldStates: [FieldState] = [] var rows: [[String]] = [] func startRow() { inRow = true rowRootIndex = stack.count - 1 fieldStates = Array(repeating: .pending, count: specs.count) tryMatchFields(closedAttrs: nil) } func finishRow() { let columns = fieldStates.map { state -> String in switch state { case .pending: return "" case .capturing(_, let buf): return buf.trimmingCharacters(in: .whitespaces) case .done(let value): return value.trimmingCharacters(in: .whitespaces) } } rows.append(columns) inRow = false rowRootIndex = -1 fieldStates = [] } // closedAttrs non-nil = the just-seen element is void/self-closed and // sits on top of the stack only transiently. func tryMatchFields(closedAttrs: [String: String]?) { guard inRow else { return } for i in specs.indices { guard case .pending = fieldStates[i] else { continue } guard matchChain(specs[i].compounds, stack: stack, scopeStart: rowRootIndex) else { continue } if let attr = specs[i].attr { fieldStates[i] = .done(stack[stack.count - 1].attrs[attr] ?? "") } else if closedAttrs != nil { fieldStates[i] = .done("") // void element has no text } else { fieldStates[i] = .capturing(rootIndex: stack.count - 1, buf: "") } } } tokenize(html, openTag: { tag, attrs, closed in let el: StackEl = (tag, normalizedID(attrs), Set(splitClasses(attrs["class"])), attrs) if closed { stack.append(el) if inRow { tryMatchFields(closedAttrs: attrs) } stack.removeLast() return } stack.append(el) if matchChain(rowCompounds, stack: stack, scopeStart: 0) { if inRow { finishRow() } // unclosed previous row startRow() } else if inRow { tryMatchFields(closedAttrs: nil) } }, closeTag: { tag in guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } for i in fieldStates.indices { if case .capturing(let root, let buf) = fieldStates[i], root >= matchIdx { fieldStates[i] = .done(buf) } } if inRow && rowRootIndex >= matchIdx { finishRow() } stack.removeSubrange(matchIdx...) }, text: { raw in guard inRow else { return } let collapsed = collapseWhitespace(decodeEntities(raw)) guard !collapsed.isEmpty else { return } for i in fieldStates.indices { if case .capturing(let root, var buf) = fieldStates[i] { if !buf.isEmpty { buf += " " } buf += collapsed fieldStates[i] = .capturing(rootIndex: root, buf: buf) } } }) if inRow { finishRow() } return RowExtraction(header: specs.map { $0.name }, rows: rows) } /// True if the selector parses (for validating user/profile input). static func isValidSelector(_ selector: String) -> Bool { parseSelector(selector) != nil } // MARK: - Selector parsing & matching private static func parseSelector(_ selector: String) -> [Compound]? { let parts = selector.split(separator: " ").map(String.init) guard !parts.isEmpty else { return nil } var compounds: [Compound] = [] for part in parts { guard let c = Compound(token: part) else { return nil } compounds.append(c) } // @attr extraction is only meaningful on the final compound for c in compounds.dropLast() where c.extractAttr != nil { return nil } return compounds } /// Match the last compound against the top of the stack and earlier /// compounds against ancestors in order, never looking below scopeStart. private static func matchChain(_ compounds: [Compound], stack: [(tag: String, id: String?, classes: Set, attrs: [String: String])], scopeStart: Int) -> Bool { guard let last = stack.last, compounds[compounds.count - 1].matches(tag: last.tag, id: last.id, classes: last.classes, attrs: last.attrs) else { return false } var ci = compounds.count - 2 var si = stack.count - 2 while ci >= 0 { while si >= scopeStart, !compounds[ci].matches(tag: stack[si].tag, id: stack[si].id, classes: stack[si].classes, attrs: stack[si].attrs) { si -= 1 } if si < scopeStart { return false } ci -= 1 si -= 1 } return true } private struct AttrFilter { enum Op { case present, absent, equals, prefix } let name: String let op: Op let value: String init?(_ content: String) { let s = content.trimmingCharacters(in: .whitespaces) guard !s.isEmpty else { return nil } if s.hasPrefix("!") { let n = String(s.dropFirst()).trimmingCharacters(in: .whitespaces).lowercased() guard !n.isEmpty else { return nil } name = n; op = .absent; value = "" } else if let r = s.range(of: "^=") { let n = String(s[.. String { let t = v.trimmingCharacters(in: .whitespaces) if t.count >= 2, (t.hasPrefix("\"") && t.hasSuffix("\"")) || (t.hasPrefix("'") && t.hasSuffix("'")) { return String(t.dropFirst().dropLast()) } return t } func matches(_ attrs: [String: String]) -> Bool { switch op { case .present: return attrs[name] != nil case .absent: return attrs[name] == nil case .equals: return attrs[name] == value case .prefix: return attrs[name]?.hasPrefix(value) ?? false } } } private struct Compound { var tag: String? var id: String? var classes: [String] = [] var filters: [AttrFilter] = [] var extractAttr: String? init?(token: String) { var mode: Character = "t" var current = "" func commit() -> Bool { guard !current.isEmpty else { return false } switch mode { case "t": guard tag == nil else { return false } // "div[x]y" — two tags let t = current.lowercased() // Reject combinators/pseudo junk (">", "+", "td:nth-child(2)") // so the "Bad Selector" path fires instead of 0 matches. // ':' stays legal (namespaced tags); ids/classes aren't // validated — real class values contain odd characters. guard t == "*" || t.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == ":" }) else { return false } tag = t case "#": id = current default: classes.append(current) } current = "" return true } let chars = Array(token) var i = 0 while i < chars.count { let ch = chars[i] if ch == "[" { if !current.isEmpty { guard commit() else { return nil } } else if mode != "t" { return nil // ".[x]" } mode = "t" guard let close = chars[i...].firstIndex(of: "]") else { return nil } guard let filter = AttrFilter(String(chars[(i + 1).., attrs: [String: String]) -> Bool { if let tag, tag != t { return false } if let id, id != i { return false } for c in classes where !cs.contains(c) { return false } for f in filters where !f.matches(attrs) { return false } return true } } // MARK: - Tokenizer private static let voidTags: Set = [ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ] private static let rawTextTags: Set = ["script", "style", "textarea", "title"] // Tags that implicitly close a same-tag sibling (covers unclosed //
  • //

    runs in raw page source; inspector copies are balanced). private static let siblingClosingTags: Set = ["li", "tr", "td", "th", "p", "dt", "dd", "option"] // Inline tags the sibling-close rule may look "through" — an unclosed // between two

  • s must not defeat the implicit close. Bounded so // legitimately nested lists (li inside ul inside li) stay nested. private static let inlineTags: Set = [ "a", "abbr", "b", "code", "em", "font", "i", "label", "mark", "s", "small", "span", "strong", "sub", "sup", "time", "u", ] /// Walks the markup once. openTag receives the tag name, all attributes /// (lowercased names, entity-decoded values, first occurrence wins), and /// whether the element can't have children (self-closed or void). private static func tokenize(_ html: String, openTag: (String, [String: String], Bool) -> Void, closeTag: (String) -> Void, text: (String) -> Void) { let bytes = Array(html.utf8) let n = bytes.count let lt = UInt8(ascii: "<"), gt = UInt8(ascii: ">") let slash = UInt8(ascii: "/"), bang = UInt8(ascii: "!") let eq = UInt8(ascii: "="), dash = UInt8(ascii: "-") let dq = UInt8(ascii: "\""), sq = UInt8(ascii: "'") func isSpace(_ b: UInt8) -> Bool { b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C } func isAlpha(_ b: UInt8) -> Bool { (b | 0x20) >= 0x61 && (b | 0x20) <= 0x7A } func isNameByte(_ b: UInt8) -> Bool { isAlpha(b) || (b >= 0x30 && b <= 0x39) || b == dash || b == UInt8(ascii: "_") || b == UInt8(ascii: ":") } var i = 0 var textStart = 0 var openStack: [String] = [] func flushText(upTo end: Int) { if end > textStart { text(String(decoding: bytes[textStart.. and close // (their terminator overlaps the opening dashes). var j = i + 2 while j + 2 < n && !(bytes[j] == dash && bytes[j + 1] == dash && bytes[j + 2] == gt) { j += 1 } i = min(n, j + 3) } else { var j = i + 2 while j < n && bytes[j] != gt { j += 1 } i = min(n, j + 1) } textStart = i } else if next == slash { flushText(upTo: i) var j = i + 2 var name = "" while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 } while j < n && bytes[j] != gt { j += 1 } i = min(n, j + 1) textStart = i if !name.isEmpty { emitClose(name.lowercased()) } } else if isAlpha(next) { flushText(upTo: i) var j = i + 1 var name = "" while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 } let tag = name.lowercased() var attrs: [String: String] = [:] var selfClosed = false attrs: while j < n { while j < n, isSpace(bytes[j]) { j += 1 } if j >= n { break } if bytes[j] == gt { j += 1; break } if bytes[j] == slash { if j + 1 < n, bytes[j + 1] == gt { selfClosed = true; j += 2; break } j += 1 continue } var attrName = "" while j < n, !isSpace(bytes[j]), bytes[j] != eq, bytes[j] != gt, bytes[j] != slash { attrName.append(Character(UnicodeScalar(bytes[j]))) j += 1 } var value = "" while j < n, isSpace(bytes[j]) { j += 1 } if j < n, bytes[j] == eq { j += 1 while j < n, isSpace(bytes[j]) { j += 1 } if j < n, bytes[j] == dq || bytes[j] == sq { let quote = bytes[j] j += 1 let start = j while j < n, bytes[j] != quote { j += 1 } value = String(decoding: bytes[start..= n { break attrs } } i = j textStart = i if siblingClosingTags.contains(tag) { var idx = openStack.count - 1 while idx >= 0, inlineTags.contains(openStack[idx]) { idx -= 1 } if idx >= 0, openStack[idx] == tag { emitClose(tag) } } let childless = selfClosed || voidTags.contains(tag) openTag(tag, attrs, childless) if !childless && !rawTextTags.contains(tag) { openStack.append(tag) } if !childless && rawTextTags.contains(tag) { // Skip raw content (scripts, styles) up to the real closing tag. let needle = Array(": the spec requires // '>', '/', or whitespace after the tag name. let after = k + needle.count if after < n { let b = bytes[after] if !(b == gt || b == slash || isSpace(b)) { k += 1; continue search } } break } var j2 = min(k, n) while j2 < n && bytes[j2] != gt { j2 += 1 } i = min(n, j2 + 1) textStart = i closeTag(tag) } } else { i += 1 // stray '<' in text } } flushText(upTo: n) } // MARK: - Text helpers private static func normalizedID(_ attrs: [String: String]) -> String? { guard let id = attrs["id"]?.trimmingCharacters(in: .whitespaces), !id.isEmpty else { return nil } return id } private static func splitClasses(_ value: String?) -> [String] { guard let value else { return [] } return value.split(whereSeparator: { $0.isWhitespace }).map(String.init) } private static func selectorString(tag: String, id: String?, classes: [String]) -> String { var s = tag if let id, !id.isEmpty { s += "#\(id)" } for c in classes { s += ".\(c)" } return s } private static func collapseWhitespace(_ s: String) -> String { s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") } private static func decodeEntities(_ s: String) -> String { guard s.contains("&") else { return s } var result = "" result.reserveCapacity(s.count) var i = s.startIndex while i < s.endIndex { if s[i] == "&" { var j = s.index(after: i) var entity = "" var terminated = false var steps = 0 while j < s.endIndex, steps < 12 { if s[j] == ";" { terminated = true; break } entity.append(s[j]) j = s.index(after: j) steps += 1 } if terminated, let decoded = decodeEntity(entity) { result.append(decoded) i = s.index(after: j) continue } } result.append(s[i]) i = s.index(after: i) } return result } private static func decodeEntity(_ e: String) -> Character? { switch e { case "amp": return "&" case "lt": return "<" case "gt": return ">" case "quot": return "\"" case "apos": return "'" case "nbsp": return " " case "ndash": return "–" case "mdash": return "—" case "hellip": return "…" default: if e.hasPrefix("#x") || e.hasPrefix("#X"), let v = UInt32(e.dropFirst(2), radix: 16), let u = UnicodeScalar(v) { return Character(u) } if e.hasPrefix("#"), let v = UInt32(e.dropFirst()), let u = UnicodeScalar(v) { return Character(u) } return nil } } }