diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3fab237
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/backtone
+/backtone.exe
diff --git a/LICENSE b/LICENSE
index f288702..2336df0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,8 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
+ Copyright (c) 2026 Randall Winkhart
+
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0f0109f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# Backtone
+Backtone is a program that converts websites to Atom RSS
+feeds and prints the results to stdout. It should be called via a
+server-side script that then writes the resulting XML to the webpage.
+
+The name is a playful nod toward converting contents back to the web of yesteryear.
+Why suffer in Web 2/3 when you could go "back to one"?
+
+# Dependencies
+To ensure Backtone always supports current ECMAScript standards, it needs a headless browser instance to execute client-side scripts. To avoid Cloudflare/similar challenge interference, we use [FlareSolverr](https://developer.chrome.com/blog/chrome-headless-shell). Its Docker image also contains the browser we use.
+
+# Usage
+All files needed for usage, including an the input XML, server-side script, and reverse proxy config, have examples in the `examples` folder in this repo.
+
+1. Create a server-side script that calls `backtone` with the only argument being a base64-encoded XML in the format specified when running `backbone` with no arguments
+2. Serve that script via a reverse proxy
+ - Now when a feed reader fetches the feed, the feed will be created in real time
diff --git a/atom/rss.go b/atom/rss.go
new file mode 100644
index 0000000..0627b5f
--- /dev/null
+++ b/atom/rss.go
@@ -0,0 +1,79 @@
+package atom
+
+import (
+ "errors"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gorilla/feeds"
+)
+
+// Indices indicates which capture group in the
+// regex-parsed HTML to reference for each Atom field.
+// An empty slice will use a blank/default value.
+// For the slices of strings, these will be appended between
+// captured group text.
+type IndicesT struct {
+ TitleC []string
+ TitleI []int8
+ LinkC []string
+ LinkI []int8
+ DescriptionC []string
+ DescriptionI []int8
+ AuthorNameC []string
+ AuthorNameI []int8
+ AuthorEmailC []string
+ AuthorEmailI []int8
+}
+
+func GetFromHTML(feed *feeds.Feed, rawHTML *string, regexString string, infoIndices IndicesT, maxFeedItems int) (*string, error) {
+ now := time.Now()
+ r := regexp.MustCompile(regexString)
+ newsItems := r.FindAllString(*rawHTML, 5)
+ for i, item := range newsItems {
+ info := r.FindStringSubmatch(item)
+ link := stitchFields(info, infoIndices.LinkC, infoIndices.LinkI)
+ feedItem := &feeds.Item{
+ Title: stitchFields(info, infoIndices.TitleC, infoIndices.TitleI),
+ Link: &feeds.Link{Href: link},
+ Id: "urn:backtoneid:" + strconv.Itoa(i) + ":" + link,
+ Description: stitchFields(info, infoIndices.DescriptionC, infoIndices.DescriptionI),
+ Author: &feeds.Author{
+ Name: stitchFields(info, infoIndices.AuthorNameC, infoIndices.AuthorNameI),
+ Email: stitchFields(info, infoIndices.AuthorEmailC, infoIndices.AuthorEmailI),
+ },
+ Created: now,
+ }
+ feed.Items = append(feed.Items, feedItem)
+ if i == maxFeedItems {
+ break
+ }
+ }
+ atom, err := feed.ToAtom()
+ if err != nil {
+ return nil, errors.New("unable to create Atom RSS feed: " + err.Error())
+ }
+ return &atom, nil
+}
+
+func stitchFields(info []string, connectors []string, indices []int8) string {
+ var output strings.Builder
+ if len(connectors) > 0 {
+ for i := range connectors {
+ output.WriteString(connectors[i])
+ if len(indices) >= i+1 {
+ output.WriteString(info[indices[i]])
+ }
+ }
+ } else {
+ for i := range indices {
+ if i > 0 {
+ output.WriteString(" | ")
+ }
+ output.WriteString(info[indices[i]])
+ }
+ }
+ return output.String()
+}
diff --git a/bthtml/html.go b/bthtml/html.go
new file mode 100644
index 0000000..b74465e
--- /dev/null
+++ b/bthtml/html.go
@@ -0,0 +1,50 @@
+package bthtml
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+)
+
+type payloadT struct {
+ Cmd string `json:"cmd"`
+ URL string `json:"url"`
+ MaxTimeoutMilliseconds int `json:"maxTimeout"`
+ WaitInSeconds float32 `json:"waitInSeconds"`
+}
+
+func GetFromURL(flareSolverrURL, webPageURL string, loadSeconds float32) (string, error) {
+ client := &http.Client{}
+
+ payload := payloadT{
+ Cmd: "request.get",
+ URL: webPageURL,
+ MaxTimeoutMilliseconds: 60000,
+ WaitInSeconds: loadSeconds,
+ }
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", errors.New("unable to marshal FlareSolverr payload to JSON: " + err.Error())
+ }
+
+ req, _ := http.NewRequest(
+ "POST",
+ flareSolverrURL,
+ bytes.NewBuffer(payloadBytes),
+ )
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", errors.New("unable to make FlareSolverr request to " + flareSolverrURL + ": " + err.Error())
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", errors.New("unable to read FlareSolverr response body: " + err.Error())
+ }
+ return string(body), nil
+}
diff --git a/examples/Caddyfile b/examples/Caddyfile
new file mode 100644
index 0000000..d95a064
--- /dev/null
+++ b/examples/Caddyfile
@@ -0,0 +1,22 @@
+backtone.example.com {
+
+ tls {
+ protocols tls1.3
+ }
+
+ log {
+ output file /tmp/caddy-backtone.log {
+ roll_size 2MiB
+ roll_keep 3
+ roll_keep_for 24h
+ }
+ level INFO
+ }
+
+ root * /path/to/feeds/directory
+ encode gzip
+ file_server
+
+ php_fastcgi 127.0.0.1:9000
+
+}
diff --git a/examples/feed.php b/examples/feed.php
new file mode 100644
index 0000000..270fc5a
--- /dev/null
+++ b/examples/feed.php
@@ -0,0 +1,8 @@
+
diff --git a/examples/input_xmls/chorus-encore.xml b/examples/input_xmls/chorus-encore.xml
new file mode 100644
index 0000000..8b1c789
--- /dev/null
+++ b/examples/input_xmls/chorus-encore.xml
@@ -0,0 +1 @@
+1
(.*?)<\/div>
Album:<\/span> (.*?)<\/div>Genre:<\/span> .*?<\/div>Year:<\/span> .*?<\/div><\/div><\/div>