Initial code commit

This commit is contained in:
2026-01-04 05:37:43 -05:00
parent 0af7ac5c9e
commit 7a4ef87470
11 changed files with 306 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/backtone
/backtone.exe
+2
View File
@@ -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. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
+17
View File
@@ -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
+79
View File
@@ -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()
}
+50
View File
@@ -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
}
+22
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
<?php
$command = "/usr/local/bin/backtone";
$argument = "BASE64ENCODEDINPUTXML";
$output = shell_exec(escapeshellcmd($command) . " " . escapeshellarg($argument));
header("Content-Type: application/xml; charset=utf-8");
echo $output;
?>
+1
View File
@@ -0,0 +1 @@
<input><loadSeconds>1</loadSeconds><regex><![CDATA[<ce-search-result class=\\"card rounded-none bg-base-100 shadow-xl\\"><!----><!----><div class=\\"flex\\"><img alt=\\"Album art\\" loading=\\"lazy\\" class=\\"object-cover w-40\\" src=\\".*?\\"><!----><div class=\\"flex-grow p-3\\"><div class=\\"text-xl \[text-wrap:balance\] sm:text-2xl\\">(.*?)<\/div><!----><div><span class=\\"text-sm font-bold \[text-wrap:balance\] sm:text-base\\">Album:<\/span> (.*?)<\/div><div><span class=\\"text-sm font-bold \[text-wrap:balance\] sm:text-base\\">Genre:<\/span> .*?<\/div><div><span class=\\"text-sm font-bold \[text-wrap:balance\] sm:text-base\\">Year:<\/span> .*?<\/div><!----><\/div><\/div><ce-search-result-chart><div class=\\"collapse overflow-visible rounded-none border border-base-300 bg-base-200 collapse-close\\"><div class=\\"collapse-title flex flex-wrap items-center justify-end gap-3 rounded-none px-3 py-2\\"><div class=\\"mr-auto flex items-center gap-3\\"><div class=\\"swap swap-rotate cursor-pointer\\"><i class=\\"bi bi-chevron-down swap-off text-xl\\"><\/i><i class=\\"bi bi-chevron-up swap-on text-xl\\"><\/i><\/div><div class=\\"tooltip min-w-max\\"><img class=\\"w-16\\" .*?\\"><\/div><!----><\/div><div class=\\"flex flex-1 items-center gap-3\\" style=\\"flex-basis: 9rem;\\"><div class=\\"flex flex-wrap items-center gap-3 \[word-break:break-word\]\\"><div><div class=\\"footer-title mb-0\\">Charter:<\/div><a target=\\"_blank\\" class=\\"link-hover link\\" href=\\".*?\\">(.*?)<\/a><\/div>.*?for=\\"downloadVideos_(.*?)\\"]]></regex><flareSolverrURL>http://PLACEHOLDER:8191/v1</flareSolverrURL><maxFeedItems>10</maxFeedItems><feed><title><parent>Chorus Encore</parent><captureGroupIndex>1</captureGroupIndex></title><link><parent>https://www.enchor.us/</parent><captureGroupConnector>https://www.enchor.us/chart/</captureGroupConnector><captureGroupIndex>4</captureGroupIndex></link><description><parent>Latest YARG/CH charts published to Chorus Encore</parent><captureGroupConnector>Charter: </captureGroupConnector><captureGroupIndex>3</captureGroupIndex><captureGroupConnector> | Album: </captureGroupConnector><captureGroupIndex>2</captureGroupIndex></description><authorName><parent>Gamers</parent><captureGroupIndex>3</captureGroupIndex></authorName><authorEmail></authorEmail></feed></input>
+7
View File
@@ -0,0 +1,7 @@
module github.com/rwinkhart/backtone
go 1.25.5
require github.com/rwinkhart/go-boilerplate v0.1.0
require github.com/gorilla/feeds v1.2.0
+10
View File
@@ -0,0 +1,10 @@
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rwinkhart/go-boilerplate v0.1.0 h1:EzlVj6R7Bxtl79Nl7R5zRcg6s+Cf2FAqGIzR4giWTQg=
github.com/rwinkhart/go-boilerplate v0.1.0/go.mod h1:cnzIF45I0FCOvE4YIB+26pLCUx2kWyY2llKYZruNaRY=
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"encoding/base64"
"encoding/xml"
"fmt"
"os"
"github.com/gorilla/feeds"
"github.com/rwinkhart/backtone/atom"
"github.com/rwinkhart/backtone/bthtml"
"github.com/rwinkhart/go-boilerplate/other"
)
const cfgPath = "/etc/backtone.xml"
// feedFieldInfo provides the string value for a given field in the parent feed
// and specifies which index in the regex capture group to extract the same field
// from per individual news item.
// An index of -1 can be used to use the same value as the parent feed.
// An index of -2 can be used to use a default (placeholder) value.
type feedFieldInfoT struct {
Parent string `xml:"parent,omitempty"`
CaptureGroupConnector []string `xml:"captureGroupConnector,omitempty"`
CaptureGroupIndex []int8 `xml:"captureGroupIndex,omitempty"`
}
type feedT struct {
Title feedFieldInfoT `xml:"title"`
Link feedFieldInfoT `xml:"link"`
Description feedFieldInfoT `xml:"description"`
AuthorName feedFieldInfoT `xml:"authorName"`
AuthorEmail feedFieldInfoT `xml:"authorEmail"`
}
type inputT struct {
LoadSeconds float32 `xml:"loadSeconds"`
Regex string `xml:"regex"`
FlareSolverrURL string `xml:"flareSolverrURL"`
MaxFeedItems int `xml:"maxFeedItems"`
Feed feedT `xml:"feed"`
}
func main() {
// handle input argument (XML)
if len(os.Args) < 2 {
demoInputBytes, _ := xml.MarshalIndent(
inputT{
LoadSeconds: 2.5,
Regex: "PLACEHOLDER",
FlareSolverrURL: "http://127.0.0.1:8191",
MaxFeedItems: 10,
Feed: feedT{
Title: feedFieldInfoT{Parent: "Demo Title", CaptureGroupIndex: []int8{0}},
Link: feedFieldInfoT{Parent: "https://example.com/", CaptureGroupIndex: []int8{1}},
Description: feedFieldInfoT{Parent: "Demo description", CaptureGroupConnector: []string{"Prefix - ", " - Middle - ", " - Suffix"}, CaptureGroupIndex: []int8{1, 3}},
AuthorName: feedFieldInfoT{Parent: "John Doe", CaptureGroupIndex: []int8{3}},
AuthorEmail: feedFieldInfoT{Parent: "johndoe@example.com", CaptureGroupIndex: []int8{4}},
},
},
"",
" ",
)
other.PrintError(os.Args[0]+" must be called with valid base64-encoded XML input using the following schema:\n\n"+string(demoInputBytes), 1)
}
// decode base64 input XML
decodedXML, err := base64.StdEncoding.DecodeString(os.Args[1])
if err != nil {
other.PrintError("Failed to decode base64 input XML: "+err.Error(), 1)
}
var inputInfo inputT
if err := xml.Unmarshal(decodedXML, &inputInfo); err != nil {
other.PrintError("Failed to unmarshal input XML: "+err.Error(), 1)
}
rawHTML, err := bthtml.GetFromURL(inputInfo.FlareSolverrURL, inputInfo.Feed.Link.Parent, inputInfo.LoadSeconds)
if err != nil {
other.PrintError("Failed to get HTML content: "+err.Error(), 1)
}
atomXML, err := atom.GetFromHTML(
&feeds.Feed{
Title: inputInfo.Feed.Title.Parent,
Link: &feeds.Link{Href: inputInfo.Feed.Link.Parent},
Description: inputInfo.Feed.Description.Parent,
Author: &feeds.Author{Name: inputInfo.Feed.AuthorName.Parent, Email: inputInfo.Feed.AuthorEmail.Parent},
},
&rawHTML,
inputInfo.Regex,
atom.IndicesT{
TitleC: inputInfo.Feed.Title.CaptureGroupConnector,
TitleI: inputInfo.Feed.Title.CaptureGroupIndex,
LinkC: inputInfo.Feed.Link.CaptureGroupConnector,
LinkI: inputInfo.Feed.Link.CaptureGroupIndex,
DescriptionC: inputInfo.Feed.Description.CaptureGroupConnector,
DescriptionI: inputInfo.Feed.Description.CaptureGroupIndex,
AuthorNameC: inputInfo.Feed.AuthorName.CaptureGroupConnector,
AuthorNameI: inputInfo.Feed.AuthorName.CaptureGroupIndex,
AuthorEmailC: inputInfo.Feed.AuthorEmail.CaptureGroupConnector,
AuthorEmailI: inputInfo.Feed.AuthorEmail.CaptureGroupIndex,
},
inputInfo.MaxFeedItems,
)
if err != nil {
other.PrintError("Failed to generate Atom RSS feed from HTML:\n\n"+rawHTML+"\n\n"+err.Error(), 1)
}
fmt.Println(*atomXML)
}