|
| 1 | +import Foundation |
| 2 | + |
| 3 | +protocol StockMarketProviding: Sendable { |
| 4 | + func latestMarket() async throws -> StockMarketSnapshot |
| 5 | +} |
| 6 | + |
| 7 | +/// Reads ShareSansar's public, server-rendered market and price tables. A |
| 8 | +/// single price-table request supplies every watched symbol, rather than |
| 9 | +/// requesting individual company pages as the watchlist grows. |
| 10 | +struct ShareSansarStockProvider: StockMarketProviding { |
| 11 | + private let session: URLSession |
| 12 | + |
| 13 | + init(session: URLSession? = nil) { |
| 14 | + self.session = session ?? .sajilo() |
| 15 | + } |
| 16 | + |
| 17 | + private static let marketURL = URL(string: "https://www.sharesansar.com/index.php/market")! |
| 18 | + private static let pricesURL = URL(string: "https://www.sharesansar.com/index.php/today-share-price")! |
| 19 | + |
| 20 | + func latestMarket() async throws -> StockMarketSnapshot { |
| 21 | + async let marketHTML = fetch(Self.marketURL) |
| 22 | + async let pricesHTML = fetch(Self.pricesURL) |
| 23 | + let (market, prices) = try await (marketHTML, pricesHTML) |
| 24 | + return try Self.parse(marketHTML: market, pricesHTML: prices, fetchedAt: .now) |
| 25 | + } |
| 26 | + |
| 27 | + private func fetch(_ url: URL) async throws -> String { |
| 28 | + var request = URLRequest(url: url) |
| 29 | + request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Sajilo/1.0", forHTTPHeaderField: "User-Agent") |
| 30 | + let (data, response) = try await session.data(for: request) |
| 31 | + guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode, |
| 32 | + let html = String(data: data, encoding: .utf8) else { |
| 33 | + throw StockMarketProviderError.invalidResponse |
| 34 | + } |
| 35 | + return html |
| 36 | + } |
| 37 | + |
| 38 | + static func parse(marketHTML: String, pricesHTML: String, fetchedAt: Date) throws -> StockMarketSnapshot { |
| 39 | + let quotes = try quotes(in: pricesHTML) |
| 40 | + let indices = indices(in: marketHTML) |
| 41 | + return StockMarketSnapshot( |
| 42 | + nepse: indices.first { $0.name.localizedCaseInsensitiveContains("NEPSE") }, |
| 43 | + subIndices: subIndices(in: marketHTML), |
| 44 | + movers: movers(in: marketHTML), |
| 45 | + quotes: quotes, |
| 46 | + publishedOn: publishedDate(in: pricesHTML) ?? publishedDate(in: marketHTML), |
| 47 | + fetchedAt: fetchedAt |
| 48 | + ) |
| 49 | + } |
| 50 | + |
| 51 | + static func quotes(in html: String) throws -> [StockQuote] { |
| 52 | + guard let table = HTMLTable.allTableRows(in: html).first(where: { rows in |
| 53 | + rows.first?.contains("Symbol") == true && rows.first?.contains("LTP") == true |
| 54 | + }) else { throw StockMarketProviderError.tableNotFound } |
| 55 | + |
| 56 | + let companyNames = companyNames(in: html) |
| 57 | + let quotes = table.dropFirst().compactMap { row -> StockQuote? in |
| 58 | + // S.No, Symbol, Conf., Open, High, Low, Close, LTP, Close-LTP, |
| 59 | + // Close-LTP %, VWAP, Vol, Prev. Close, Turnover, Trans., Diff, |
| 60 | + // Range, Diff %, Range %, VWAP %, 120 Days, 180 Days, |
| 61 | + // 52 Weeks High, 52 Weeks Low. |
| 62 | + guard row.count >= 18, |
| 63 | + let ltp = number(row[7]), |
| 64 | + let previousClose = number(row[12]), |
| 65 | + let turnover = number(row[13]), |
| 66 | + let change = number(row[15]), |
| 67 | + let changePercent = number(row[17]) else { return nil } |
| 68 | + let symbol = row[1].trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
| 69 | + guard !symbol.isEmpty else { return nil } |
| 70 | + |
| 71 | + // The trailing columns are read where present and left nil where |
| 72 | + // not, so a narrower table still yields a usable quote rather than |
| 73 | + // dropping the row. |
| 74 | + func optional(_ index: Int) -> Double? { |
| 75 | + index < row.count ? number(row[index]) : nil |
| 76 | + } |
| 77 | + |
| 78 | + return StockQuote( |
| 79 | + symbol: symbol, |
| 80 | + companyName: companyNames[symbol], |
| 81 | + ltp: ltp, |
| 82 | + previousClose: previousClose, |
| 83 | + change: change, |
| 84 | + changePercent: changePercent, |
| 85 | + open: optional(3), |
| 86 | + high: optional(4), |
| 87 | + low: optional(5), |
| 88 | + close: optional(6), |
| 89 | + vwap: optional(10), |
| 90 | + volume: optional(11), |
| 91 | + turnover: turnover, |
| 92 | + transactions: optional(14), |
| 93 | + week52High: optional(22), |
| 94 | + week52Low: optional(23), |
| 95 | + average120Day: optional(20), |
| 96 | + average180Day: optional(21) |
| 97 | + ) |
| 98 | + } |
| 99 | + guard !quotes.isEmpty else { throw StockMarketProviderError.tableNotFound } |
| 100 | + return quotes |
| 101 | + } |
| 102 | + |
| 103 | + /// The headline indices — NEPSE and its siblings — share a table shape with |
| 104 | + /// the sector sub-indices, so one reader serves both. |
| 105 | + static func indices(in html: String) -> [MarketIndex] { |
| 106 | + parseIndexTable(in: html, headingContains: "Index") |
| 107 | + } |
| 108 | + |
| 109 | + /// Banking, Hydropower, Microfinance and the rest. The headline index says |
| 110 | + /// the market moved; these say where. |
| 111 | + static func subIndices(in html: String) -> [MarketIndex] { |
| 112 | + parseIndexTable(in: html, headingContains: "Sub Index") |
| 113 | + } |
| 114 | + |
| 115 | + private static func parseIndexTable(in html: String, headingContains heading: String) -> [MarketIndex] { |
| 116 | + guard let table = HTMLTable.allTableRows(in: html).first(where: { rows in |
| 117 | + guard let header = rows.first else { return false } |
| 118 | + return header.contains(where: { $0.trimmingCharacters(in: .whitespaces) == heading }) |
| 119 | + && header.contains { $0.contains("Point") } |
| 120 | + }) else { return [] } |
| 121 | + |
| 122 | + return table.dropFirst().compactMap { row in |
| 123 | + // Name, Open, High, Low, Close, Point, % Change, Turnover. |
| 124 | + guard row.count >= 8 else { return nil } |
| 125 | + let name = row[0].trimmingCharacters(in: .whitespacesAndNewlines) |
| 126 | + guard !name.isEmpty, |
| 127 | + let value = number(row[4]), let change = number(row[5]), |
| 128 | + let percent = number(row[6]), let turnover = number(row[7]) else { return nil } |
| 129 | + return MarketIndex( |
| 130 | + name: name, |
| 131 | + value: value, |
| 132 | + change: change, |
| 133 | + changePercent: percent, |
| 134 | + turnover: turnover |
| 135 | + ) |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + /// The four leaderboards. |
| 140 | + /// |
| 141 | + /// Two traps here, both found by running this against the live page. |
| 142 | + /// Gainers and losers are *separate* tables with byte-identical headers — |
| 143 | + /// matching on the header alone finds the gainers twice and leaves losers |
| 144 | + /// empty — so they are taken in document order. And the two money tables |
| 145 | + /// put their metric first and the price second, the opposite of the |
| 146 | + /// percent tables, so every column index is stated rather than assumed. |
| 147 | + static func movers(in html: String) -> [MarketMover] { |
| 148 | + let tables = HTMLTable.allTableRows(in: html) |
| 149 | + |
| 150 | + func rows(matching heading: String) -> [[[String]]] { |
| 151 | + tables.filter { table in |
| 152 | + guard let header = table.first, header.count >= 3 else { return false } |
| 153 | + return header[0].trimmingCharacters(in: .whitespaces) == "Symbol" |
| 154 | + && header.contains { $0.localizedCaseInsensitiveContains(heading) } |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + func parse(_ table: [[String]], board: MarketMover.Board, ltp: Int, metric: Int) -> [MarketMover] { |
| 159 | + table.dropFirst().compactMap { row in |
| 160 | + guard row.count > max(ltp, metric) else { return nil } |
| 161 | + let symbol = row[0].trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
| 162 | + guard !symbol.isEmpty, let metricValue = number(row[metric]) else { return nil } |
| 163 | + return MarketMover( |
| 164 | + board: board, |
| 165 | + symbol: symbol, |
| 166 | + ltp: number(row[ltp]) ?? 0, |
| 167 | + metric: metricValue |
| 168 | + ) |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + // Symbol, LTP(Rs), Point Change, % Change — gainers first, then losers. |
| 173 | + let byPercent = rows(matching: "% Change") |
| 174 | + var movers: [MarketMover] = [] |
| 175 | + if let gainers = byPercent.first { |
| 176 | + movers += parse(gainers, board: .gainers, ltp: 1, metric: 3) |
| 177 | + } |
| 178 | + if byPercent.count > 1 { |
| 179 | + movers += parse(byPercent[1], board: .losers, ltp: 1, metric: 3) |
| 180 | + } |
| 181 | + |
| 182 | + // Symbol, TurnOvers(Rs), Ltp(Rs) — and the same shape for Volume. |
| 183 | + if let turnover = rows(matching: "TurnOver").first { |
| 184 | + movers += parse(turnover, board: .turnover, ltp: 2, metric: 1) |
| 185 | + } |
| 186 | + if let volume = rows(matching: "Volume").first { |
| 187 | + movers += parse(volume, board: .volume, ltp: 2, metric: 1) |
| 188 | + } |
| 189 | + return movers |
| 190 | + } |
| 191 | + |
| 192 | + static func number(_ text: String) -> Double? { |
| 193 | + Double(text.replacingOccurrences(of: ",", with: "").trimmingCharacters(in: .whitespacesAndNewlines)) |
| 194 | + } |
| 195 | + |
| 196 | + /// The page embeds its search directory as a JSON array. Reading names |
| 197 | + /// from that already-downloaded directory avoids one company-page request |
| 198 | + /// per watched ticker and keeps all watchlist rows from the same session. |
| 199 | + static func companyNames(in html: String) -> [String: String] { |
| 200 | + guard let marker = html.range(of: "var cmpjson ="), |
| 201 | + let start = html[marker.upperBound...].firstIndex(of: "["), |
| 202 | + let end = html[start...].range(of: "];"), |
| 203 | + let data = String(html[start...end.lowerBound]).data(using: .utf8), |
| 204 | + let records = try? JSONDecoder().decode([CompanyRecord].self, from: data) else { |
| 205 | + return [:] |
| 206 | + } |
| 207 | + return Dictionary(uniqueKeysWithValues: records.map { ($0.symbol.uppercased(), $0.companyname) }) |
| 208 | + } |
| 209 | + |
| 210 | + static func publishedDate(in html: String) -> Date? { |
| 211 | + let pattern = #"\b20\d{2}-\d{2}-\d{2}\b"# |
| 212 | + guard let range = html.range(of: pattern, options: .regularExpression) else { return nil } |
| 213 | + let formatter = DateFormatter() |
| 214 | + formatter.locale = Locale(identifier: "en_US_POSIX") |
| 215 | + formatter.dateFormat = "yyyy-MM-dd" |
| 216 | + return formatter.date(from: String(html[range])) |
| 217 | + } |
| 218 | + |
| 219 | + private struct CompanyRecord: Decodable { |
| 220 | + let symbol: String |
| 221 | + let companyname: String |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +enum StockMarketProviderError: Error, Equatable { |
| 226 | + case invalidResponse |
| 227 | + case tableNotFound |
| 228 | +} |
0 commit comments