textplus/Sources/ScrapeProfile.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
A no-Xcode AppKit app (swiftc + build.sh):
- Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find,
  live Markdown preview (split mode), selection-aware Markdown toolbar.
- Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape,
  US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel),
  freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich
  status bar. The Editor follows the selected page; layout persists to a
  foo.txt.board sidecar; the .txt stays pure plaintext.
- scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain).
- HTML scraping pipeline: custom selector engine + Discogs profile, ingests to
  Postgres (psql) into a marketplace_snapshots schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:08:59 +10:00

71 lines
2.8 KiB
Swift

import Foundation
/// A named "row + fields" extraction recipe, auto-applied when a page's URL
/// matches. The Discogs profile mirrors the selectors in the scraperrr notes.
struct ScrapeProfile {
let name: String
let urlPattern: String
let rowSelector: String
let fields: [(name: String, selector: String)]
func matchesURL(_ url: String) -> Bool {
url.range(of: urlPattern, options: [.regularExpression, .caseInsensitive]) != nil
}
static let discogsMarketplace = ScrapeProfile(
name: "discogs_marketplace",
urlPattern: #"discogs\.com/(sell/list|sell/mywants|seller/[^/]+/profile)"#,
rowSelector: "tr.shortcut_navigable",
fields: [
("item_id", "a.cart-button@data-item-id"),
("release_id", "tr@data-release-id"),
("title", "a.item_description_title"),
("listing_url", "a.item_description_title@href"),
("media_condition", ".item_condition span[!class]"),
("sleeve_condition", "span.item_sleeve_condition"),
("seller_username", ".seller_block a"),
("seller_id", "li.seller_mywants@data-seller-id"),
// Co-located with seller_id on the same element a reliable
// username fallback so a seller is never referenced unmapped.
("seller_username_alt", "li.seller_mywants@data-username"),
("price_value", "span.price@data-pricevalue"),
("price_currency", "span.price@data-currency"),
("shipping", "span.item_shipping"),
]
)
static let builtIn: [ScrapeProfile] = [discogsMarketplace]
static func matching(url: String?) -> ScrapeProfile? {
guard let url else { return nil }
return builtIn.first { $0.matchesURL(url) }
}
}
struct DetectedPage {
let url: String?
let html: String
}
enum PageDetector {
/// Recognizes the "URL on the first line, then pasted HTML" convention
/// (URL optional). Returns nil when the document doesn't look like HTML.
static func detect(in text: String) -> DetectedPage? {
var url: String?
var html = String(text.drop(while: { $0.isWhitespace }))
if html.hasPrefix("http://") || html.hasPrefix("https://") {
if let newline = html.firstIndex(of: "\n") {
let candidate = String(html[..<newline]).trimmingCharacters(in: .whitespaces)
if !candidate.contains(" "), !candidate.contains("<") {
url = candidate
html = String(html[newline...])
}
}
}
let htmlish = html.contains("<body") || html.contains("<html") || html.contains("<div")
|| html.contains("<table") || html.drop(while: { $0.isWhitespace }).hasPrefix("<")
guard htmlish else { return nil }
return DetectedPage(url: url, html: html)
}
}