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>
71 lines
2.8 KiB
Swift
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)
|
|
}
|
|
}
|