673 lines
16 KiB
Go
673 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/olekukonko/tablewriter"
|
|
"github.com/ssor/bom"
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
const (
|
|
linkFormat string = `<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>`
|
|
commentFormat string = `(?s)<!--.*-->`
|
|
genErr int = 1
|
|
gemGetLine int = 22
|
|
gophGetLine int = 33
|
|
)
|
|
|
|
// Options provide toggles and overrides to control specific rendering behaviors.
|
|
type Options struct {
|
|
PrettyTables bool // Turns on pretty ASCII rendering for table elements.
|
|
PrettyTablesOptions *PrettyTablesOptions // Configures pretty ASCII rendering for table elements.
|
|
OmitLinks bool // Turns on omitting links
|
|
TextOnly bool // Returns only plain text
|
|
}
|
|
|
|
// PrettyTablesOptions overrides tablewriter behaviors
|
|
type PrettyTablesOptions struct {
|
|
AutoFormatHeader bool
|
|
AutoWrapText bool
|
|
ReflowDuringAutoWrap bool
|
|
ColWidth int
|
|
ColumnSeparator string
|
|
RowSeparator string
|
|
CenterSeparator string
|
|
HeaderAlignment int
|
|
FooterAlignment int
|
|
Alignment int
|
|
ColumnAlignment []int
|
|
NewLine string
|
|
HeaderLine bool
|
|
RowLine bool
|
|
AutoMergeCells bool
|
|
Borders tablewriter.Border
|
|
}
|
|
|
|
// NewPrettyTablesOptions creates PrettyTablesOptions with default settings
|
|
func NewPrettyTablesOptions() *PrettyTablesOptions {
|
|
return &PrettyTablesOptions{
|
|
AutoFormatHeader: true,
|
|
AutoWrapText: true,
|
|
ReflowDuringAutoWrap: true,
|
|
ColWidth: tablewriter.MAX_ROW_WIDTH,
|
|
ColumnSeparator: tablewriter.COLUMN,
|
|
RowSeparator: tablewriter.ROW,
|
|
CenterSeparator: tablewriter.CENTER,
|
|
HeaderAlignment: tablewriter.ALIGN_DEFAULT,
|
|
FooterAlignment: tablewriter.ALIGN_DEFAULT,
|
|
Alignment: tablewriter.ALIGN_DEFAULT,
|
|
ColumnAlignment: []int{},
|
|
NewLine: tablewriter.NEWLINE,
|
|
HeaderLine: true,
|
|
RowLine: false,
|
|
AutoMergeCells: false,
|
|
Borders: tablewriter.Border{Left: true, Right: true, Bottom: true, Top: true},
|
|
}
|
|
}
|
|
|
|
// FromHTMLNode renders text output from a pre-parsed HTML document.
|
|
func FromHTMLNode(doc *html.Node, o ...Options) (string, error) {
|
|
var options Options
|
|
if len(o) > 0 {
|
|
options = o[0]
|
|
}
|
|
|
|
ctx := textifyTraverseContext{
|
|
buf: bytes.Buffer{},
|
|
options: options,
|
|
}
|
|
if err := ctx.traverse(doc); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// NOTE this was commended because it was interacting negatively
|
|
// with pre blocks where a line might start with a space.
|
|
//
|
|
var text string
|
|
if isGopher {
|
|
text = ctx.buf.String()
|
|
} else {
|
|
text = strings.TrimSpace(newlineRe.ReplaceAllString(ctx.buf.String(), "\n\n"))
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
// FromReader renders text output after parsing HTML for the specified
|
|
// io.Reader.
|
|
func FromReader(reader io.Reader, options ...Options) (string, error) {
|
|
newReader, err := bom.NewReaderWithoutBom(reader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
doc, err := html.Parse(newReader)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return FromHTMLNode(doc, options...)
|
|
}
|
|
|
|
// FromString parses HTML from the input string, then renders the text form.
|
|
func FromString(input string, options ...Options) (string, error) {
|
|
bs := bom.CleanBom([]byte(input))
|
|
text, err := FromReader(bytes.NewReader(bs), options...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
var (
|
|
spacingRe = regexp.MustCompile(`[ \r\n\t]+`)
|
|
newlineRe = regexp.MustCompile(`\n\n+`)
|
|
isGopher = false
|
|
)
|
|
|
|
// traverseTableCtx holds text-related context.
|
|
type textifyTraverseContext struct {
|
|
buf bytes.Buffer
|
|
|
|
prefix string
|
|
tableCtx tableTraverseContext
|
|
options Options
|
|
endsWithSpace bool
|
|
justClosedDiv bool
|
|
blockquoteLevel int
|
|
lineLength int
|
|
isPre bool
|
|
}
|
|
|
|
// tableTraverseContext holds table ASCII-form related context.
|
|
type tableTraverseContext struct {
|
|
header []string
|
|
body [][]string
|
|
footer []string
|
|
tmpRow int
|
|
isInFooter bool
|
|
}
|
|
|
|
func (tableCtx *tableTraverseContext) init() {
|
|
tableCtx.body = [][]string{}
|
|
tableCtx.header = []string{}
|
|
tableCtx.footer = []string{}
|
|
tableCtx.isInFooter = false
|
|
tableCtx.tmpRow = 0
|
|
}
|
|
|
|
func (ctx *textifyTraverseContext) handleElement(node *html.Node) error {
|
|
ctx.justClosedDiv = false
|
|
|
|
// TODO add support for atom.Img, it should be: [I am the alt text | http://path/to/img]
|
|
switch node.DataAtom {
|
|
case atom.Br:
|
|
return ctx.emit("\n")
|
|
|
|
case atom.H1, atom.H2, atom.H3:
|
|
subCtx := textifyTraverseContext{}
|
|
if err := subCtx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
str := strings.TrimSpace(subCtx.buf.String())
|
|
if ctx.options.TextOnly {
|
|
return ctx.emit(str + ".\n\n")
|
|
}
|
|
dividerLen := 0
|
|
for _, line := range strings.Split(str, "\n") {
|
|
if lineLen := len([]rune(line)); lineLen-1 > dividerLen {
|
|
dividerLen = lineLen
|
|
}
|
|
}
|
|
var divider string
|
|
if node.DataAtom == atom.H1 {
|
|
divider = strings.Repeat("=", dividerLen)
|
|
} else if node.DataAtom == atom.H2 {
|
|
divider = strings.Repeat("*", dividerLen)
|
|
} else if node.DataAtom == atom.H3 {
|
|
divider = strings.Repeat("-", dividerLen)
|
|
} else {
|
|
divider = strings.Repeat(".", dividerLen)
|
|
}
|
|
|
|
if node.DataAtom != atom.H1 && node.DataAtom != atom.H2 {
|
|
return ctx.emit("\n\n" + str + "\n" + divider + "\n\n")
|
|
}
|
|
return ctx.emit("\n\n" + divider + "\n" + str + "\n" + divider + "\n\n")
|
|
|
|
case atom.Blockquote:
|
|
ctx.blockquoteLevel++
|
|
if !ctx.options.TextOnly {
|
|
ctx.prefix = strings.Repeat(">", ctx.blockquoteLevel) + " "
|
|
}
|
|
if err := ctx.emit("\n"); err != nil {
|
|
return err
|
|
}
|
|
if ctx.blockquoteLevel == 1 {
|
|
if err := ctx.emit("\n"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
ctx.blockquoteLevel--
|
|
if !ctx.options.TextOnly {
|
|
ctx.prefix = strings.Repeat(">", ctx.blockquoteLevel)
|
|
}
|
|
if ctx.blockquoteLevel > 0 {
|
|
ctx.prefix += " "
|
|
}
|
|
return ctx.emit("\n\n")
|
|
|
|
case atom.Div:
|
|
if ctx.lineLength > 0 {
|
|
if err := ctx.emit("\n"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
if !ctx.justClosedDiv {
|
|
err = ctx.emit("\n")
|
|
}
|
|
ctx.justClosedDiv = true
|
|
return err
|
|
|
|
case atom.Li:
|
|
if !ctx.options.TextOnly {
|
|
if err := ctx.emit("* "); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctx.emit("\n")
|
|
|
|
case atom.Dt:
|
|
if !ctx.options.TextOnly {
|
|
if err := ctx.emit("* "); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctx.emit("\n")
|
|
|
|
case atom.Dd:
|
|
if !ctx.options.TextOnly {
|
|
if err := ctx.emit("\t "); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctx.emit("\n")
|
|
|
|
case atom.B, atom.Strong:
|
|
subCtx := textifyTraverseContext{}
|
|
subCtx.endsWithSpace = true
|
|
if err := subCtx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
str := subCtx.buf.String()
|
|
if ctx.options.TextOnly {
|
|
return ctx.emit(str + ".")
|
|
}
|
|
return ctx.emit("*" + str + "*")
|
|
|
|
case atom.A:
|
|
linkText := ""
|
|
// For simple link element content with single text node only, peek at the link text.
|
|
if node.FirstChild != nil && node.FirstChild.NextSibling == nil && node.FirstChild.Type == html.TextNode {
|
|
linkText = node.FirstChild.Data
|
|
}
|
|
|
|
// If image is the only child, take its alt text as the link text.
|
|
if img := node.FirstChild; img != nil && node.LastChild == img && img.DataAtom == atom.Img {
|
|
if altText := getAttrVal(img, "alt"); altText != "" {
|
|
if err := ctx.emit(altText); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
hrefLink := ""
|
|
if attrVal := getAttrVal(node, "href"); attrVal != "" {
|
|
attrVal = ctx.normalizeHrefLink(attrVal)
|
|
// Don't print link href if it matches link element content or if the link is empty.
|
|
if (attrVal != "" && linkText != attrVal) && !ctx.options.OmitLinks && !ctx.options.TextOnly {
|
|
hrefLink = "( " + attrVal + " )"
|
|
}
|
|
}
|
|
|
|
return ctx.emit(hrefLink)
|
|
|
|
case atom.P, atom.Ul, atom.Dl:
|
|
return ctx.paragraphHandler(node)
|
|
|
|
case atom.Table, atom.Tfoot, atom.Th, atom.Tr, atom.Td:
|
|
if ctx.options.PrettyTables {
|
|
return ctx.handleTableElement(node)
|
|
} else if node.DataAtom == atom.Table {
|
|
return ctx.paragraphHandler(node)
|
|
}
|
|
return ctx.traverseChildren(node)
|
|
|
|
case atom.Pre:
|
|
ctx.isPre = true
|
|
err := ctx.traverseChildren(node)
|
|
ctx.isPre = false
|
|
return err
|
|
|
|
case atom.Style, atom.Script, atom.Head:
|
|
// Ignore the subtree.
|
|
return nil
|
|
|
|
default:
|
|
return ctx.traverseChildren(node)
|
|
}
|
|
}
|
|
|
|
// paragraphHandler renders node children surrounded by double newlines.
|
|
func (ctx *textifyTraverseContext) paragraphHandler(node *html.Node) error {
|
|
if err := ctx.emit("\n\n"); err != nil {
|
|
return err
|
|
}
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
return ctx.emit("\n\n")
|
|
}
|
|
|
|
// handleTableElement is only to be invoked when options.PrettyTables is active.
|
|
func (ctx *textifyTraverseContext) handleTableElement(node *html.Node) error {
|
|
if !ctx.options.PrettyTables {
|
|
panic("handleTableElement invoked when PrettyTables not active")
|
|
}
|
|
|
|
switch node.DataAtom {
|
|
case atom.Table:
|
|
if err := ctx.emit("\n\n"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Re-intialize all table context.
|
|
ctx.tableCtx.init()
|
|
|
|
// Browse children, enriching context with table data.
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
|
|
buf := &bytes.Buffer{}
|
|
table := tablewriter.NewWriter(buf)
|
|
if ctx.options.PrettyTablesOptions != nil {
|
|
options := ctx.options.PrettyTablesOptions
|
|
table.SetAutoFormatHeaders(options.AutoFormatHeader)
|
|
table.SetAutoWrapText(options.AutoWrapText)
|
|
table.SetReflowDuringAutoWrap(options.ReflowDuringAutoWrap)
|
|
table.SetColWidth(options.ColWidth)
|
|
table.SetColumnSeparator(options.ColumnSeparator)
|
|
table.SetRowSeparator(options.RowSeparator)
|
|
table.SetCenterSeparator(options.CenterSeparator)
|
|
table.SetHeaderAlignment(options.HeaderAlignment)
|
|
table.SetFooterAlignment(options.FooterAlignment)
|
|
table.SetAlignment(options.Alignment)
|
|
table.SetColumnAlignment(options.ColumnAlignment)
|
|
table.SetNewLine(options.NewLine)
|
|
table.SetHeaderLine(options.HeaderLine)
|
|
table.SetRowLine(options.RowLine)
|
|
table.SetAutoMergeCells(options.AutoMergeCells)
|
|
table.SetBorders(options.Borders)
|
|
}
|
|
table.SetHeader(ctx.tableCtx.header)
|
|
table.SetFooter(ctx.tableCtx.footer)
|
|
table.AppendBulk(ctx.tableCtx.body)
|
|
|
|
// Render the table using ASCII.
|
|
table.Render()
|
|
if err := ctx.emit(buf.String()); err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctx.emit("\n\n")
|
|
|
|
case atom.Tfoot:
|
|
ctx.tableCtx.isInFooter = true
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
ctx.tableCtx.isInFooter = false
|
|
|
|
case atom.Tr:
|
|
ctx.tableCtx.body = append(ctx.tableCtx.body, []string{})
|
|
if err := ctx.traverseChildren(node); err != nil {
|
|
return err
|
|
}
|
|
ctx.tableCtx.tmpRow++
|
|
|
|
case atom.Th:
|
|
res, err := ctx.renderEachChild(node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx.tableCtx.header = append(ctx.tableCtx.header, res)
|
|
|
|
case atom.Td:
|
|
res, err := ctx.renderEachChild(node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if ctx.tableCtx.isInFooter {
|
|
ctx.tableCtx.footer = append(ctx.tableCtx.footer, res)
|
|
} else {
|
|
ctx.tableCtx.body[ctx.tableCtx.tmpRow] = append(ctx.tableCtx.body[ctx.tableCtx.tmpRow], res)
|
|
}
|
|
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ctx *textifyTraverseContext) traverse(node *html.Node) error {
|
|
switch node.Type {
|
|
default:
|
|
return ctx.traverseChildren(node)
|
|
|
|
case html.TextNode:
|
|
var data string
|
|
if ctx.isPre {
|
|
data = node.Data
|
|
// TODO something is happening that is weird here.
|
|
// It looks good as data here, but something happens with spacing that
|
|
// just isnt right... not sure what it is
|
|
} else {
|
|
data = strings.TrimSpace(spacingRe.ReplaceAllString(node.Data, " "))
|
|
}
|
|
return ctx.emit(data)
|
|
|
|
case html.ElementNode:
|
|
return ctx.handleElement(node)
|
|
}
|
|
}
|
|
|
|
func (ctx *textifyTraverseContext) traverseChildren(node *html.Node) error {
|
|
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
|
if err := ctx.traverse(c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ctx *textifyTraverseContext) emit(data string) error {
|
|
if data == "" {
|
|
return nil
|
|
}
|
|
var (
|
|
lines = ctx.breakLongLines(data)
|
|
err error
|
|
)
|
|
for _, line := range lines {
|
|
runes := []rune(line)
|
|
// XXX I cannot figure out for the life of me why a single space indent
|
|
// was being added here. But it was really wonky... so let's ditch it?
|
|
//
|
|
// startsWithSpace := unicode.IsSpace(runes[0])
|
|
// if !startsWithSpace && !ctx.endsWithSpace && !strings.HasPrefix(data, ".") {
|
|
// if err = ctx.buf.WriteByte(' '); err != nil {
|
|
// return err
|
|
// }
|
|
// ctx.lineLength++
|
|
// }
|
|
ctx.endsWithSpace = unicode.IsSpace(runes[len(runes)-1])
|
|
for _, c := range line {
|
|
if _, err = ctx.buf.WriteString(string(c)); err != nil {
|
|
return err
|
|
}
|
|
ctx.lineLength++
|
|
if c == '\n' {
|
|
ctx.lineLength = 0
|
|
if ctx.prefix != "" {
|
|
if _, err = ctx.buf.WriteString(ctx.prefix); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const maxLineLen = 74
|
|
|
|
func (ctx *textifyTraverseContext) breakLongLines(data string) []string {
|
|
// Only break lines when in blockquotes.
|
|
if ctx.blockquoteLevel == 0 {
|
|
return []string{data}
|
|
}
|
|
var (
|
|
ret = []string{}
|
|
runes = []rune(data)
|
|
l = len(runes)
|
|
existing = ctx.lineLength
|
|
)
|
|
if existing >= maxLineLen {
|
|
ret = append(ret, "\n")
|
|
existing = 0
|
|
}
|
|
for l+existing > maxLineLen {
|
|
i := maxLineLen - existing
|
|
for i >= 0 && !unicode.IsSpace(runes[i]) {
|
|
i--
|
|
}
|
|
if i == -1 {
|
|
// No spaces, so go the other way.
|
|
i = maxLineLen - existing
|
|
for i < l && !unicode.IsSpace(runes[i]) {
|
|
i++
|
|
}
|
|
}
|
|
ret = append(ret, string(runes[:i])+"\n")
|
|
for i < l && unicode.IsSpace(runes[i]) {
|
|
i++
|
|
}
|
|
runes = runes[i:]
|
|
l = len(runes)
|
|
existing = 0
|
|
}
|
|
if len(runes) > 0 {
|
|
ret = append(ret, string(runes))
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func (ctx *textifyTraverseContext) normalizeHrefLink(link string) string {
|
|
link = strings.TrimSpace(link)
|
|
link = strings.TrimPrefix(link, "mailto:")
|
|
return link
|
|
}
|
|
|
|
// renderEachChild visits each direct child of a node and collects the sequence of
|
|
// textuual representaitons separated by a single newline.
|
|
func (ctx *textifyTraverseContext) renderEachChild(node *html.Node) (string, error) {
|
|
buf := &bytes.Buffer{}
|
|
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
|
s, err := FromHTMLNode(c, ctx.options)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err = buf.WriteString(s); err != nil {
|
|
return "", err
|
|
}
|
|
if c.NextSibling != nil {
|
|
if err = buf.WriteByte('\n'); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func getAttrVal(node *html.Node, attrName string) string {
|
|
for _, attr := range node.Attr {
|
|
if attr.Key == attrName {
|
|
return attr.Val
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
type urlList struct {
|
|
l []string
|
|
}
|
|
|
|
func (u urlList) String() string {
|
|
var b strings.Builder
|
|
b.WriteString("\n\nReferences:\n\n")
|
|
for i, s := range u.l {
|
|
b.WriteString(fmt.Sprintf(" %d. %s\n", i+1, s))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (u *urlList) Append(s string) {
|
|
u.l = append(u.l, s)
|
|
}
|
|
|
|
func NewUrlList(initCap int) urlList {
|
|
if initCap < 5 {
|
|
initCap = 5
|
|
}
|
|
return urlList{make([]string, 0, initCap)}
|
|
}
|
|
|
|
func Render(ur string) (string, error) {
|
|
u, err := url.Parse(ur)
|
|
if err != nil {
|
|
return "", errors.Join(errors.New("Could not parse url"), err)
|
|
}
|
|
page, actualU, err := getURL(u)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Actual U is the final URL after redirects
|
|
u = actualU
|
|
commentRe := regexp.MustCompile(commentFormat)
|
|
page = commentRe.ReplaceAllString(page, ``)
|
|
uList := NewUrlList(-1)
|
|
linkRe := regexp.MustCompile(linkFormat)
|
|
for _, s := range linkRe.FindAllStringSubmatch(page, -1) {
|
|
page = strings.Replace(page, s[0], fmt.Sprintf(`%s[%d]`, s[2], len(uList.l)+1), -1)
|
|
href := s[1]
|
|
if !strings.Contains(href, "://") {
|
|
ref, err := url.Parse(href)
|
|
if err == nil {
|
|
href = u.ResolveReference(ref).String()
|
|
}
|
|
}
|
|
uList.Append(href)
|
|
}
|
|
plain, _ := FromString(page)
|
|
return fmt.Sprintf("%s\n\n%s\n\n%s", u.String(), plain, uList.String()), nil
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Print("Expected a URL as an argument")
|
|
os.Exit(genErr)
|
|
}
|
|
|
|
s, err := Render(os.Args[1])
|
|
if err != nil {
|
|
fmt.Print(err.Error())
|
|
os.Exit(genErr)
|
|
}
|
|
fmt.Println(s)
|
|
}
|