mirror of
https://github.com/rwinkhart/backtone.git
synced 2026-08-28 12:56:34 -04:00
Initial code commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/backtone
|
||||
/backtone.exe
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
?>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user