# paperdoorknob: Print glowfic # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the # Free Software Foundation, version 3. import dataclasses import itertools from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse from typing import Any, Iterable from bs4 import BeautifulSoup from bs4.element import Tag from images import ImageStore from spec import Spec from texify import Texifier def ilen(it: Iterable[Any]) -> int: return sum(1 for _ in it) def _removeViewFromURL(url: str) -> str: u = urlparse(url) old_qs = parse_qsl(u.query) new_qs = [(k, v) for k, v in old_qs if k != 'view'] return urlunparse(u._replace(query=urlencode(new_qs))) def nonFlatURL(url: str) -> str: return _removeViewFromURL(url) def flatURL(url: str) -> str: u = urlparse(_removeViewFromURL(url)) qs = parse_qsl(u.query) + [('view', 'flat')] return urlunparse(u._replace(query=urlencode(qs))) @dataclasses.dataclass(frozen=True) class Chunk: icon: str | None character: Tag | None screen_name: Tag | None author: Tag | None content: Tag # We avoid the name "post" because the Glowfic community uses the term # inconsistently: # * The Glowfic software sometimes uses "post" to refer to a whole thread # (in the URL), sometimes uses "post" to refer to chunks (in the CSS), # but mostly uses "post" to refer to just the first chunk in a thread # (in the HTML and UI). The non-first chunks are "replies". # * Readers and this software don't need to distinguish first-chunks and # non-first-chunks. # * Humans in the community tend to use "posts" to mean chunks. class Thread: def __init__(self, spec: Spec) -> None: def find_next_thread(dom: BeautifulSoup) -> str | None: for c in dom.findChildren('div', class_='post-navheader'): for a in c.findChildren('a'): if 'Next Post' in a.text and 'href' in a.attrs and isinstance( a.attrs['href'], str): return urljoin(spec.url, a.attrs['href']) return None spec.log('Fetching HTML...\r') html = spec.fetcher.fetch(spec.url) flat_html = spec.fetcher.fetch(flatURL(spec.url)) spec.log('Parsing HTML...\r') self._next_thread = find_next_thread( BeautifulSoup(spec.htmlfilter(html), 'html.parser')) self._dom = BeautifulSoup(spec.htmlfilter(flat_html), 'html.parser') self._spec = spec def title(self) -> str | None: span = self._dom.findChild("span", id="post-title") if not isinstance(span, Tag): return None return span.text.strip() def next_thread(self) -> str | None: return self._next_thread def chunkDOMs(self) -> Iterable[Tag]: def text() -> Tag: body = self._dom.body assert body text = body.find_next("div", class_="post-post") assert isinstance(text, Tag) return text def the_replies() -> Iterable[Tag]: rs = self._dom.find_all("div", class_="post-reply") assert all(isinstance(r, Tag) for r in rs) return rs return itertools.chain([text()], the_replies()) def emit(self) -> None: self._spec.log('Counting chunks...\r') num_chunks = ilen(self.chunkDOMs()) title = self.title() or "chunk" for i, r in enumerate(self.chunkDOMs()): percent = 100.0 * (i + 1) / num_chunks self._spec.log( f'Processing {title} {i+1} of {num_chunks} ({percent:.1f}%)\r') self._spec.domfilter(r) chunk = makeChunk(r, self._spec.images) self._spec.texout.write( self._spec.texfilter(renderChunk(self._spec.texifier, chunk))) self._spec.log('') next_url = self.next_thread() if next_url is not None: Thread(dataclasses.replace(self._spec, url=next_url)).emit() def makeChunk(chunk_dom: Tag, image_store: ImageStore) -> Chunk: def getIcon() -> str | None: icon_div = chunk_dom.findChild('div', class_='post-icon') if icon_div is None: return None assert isinstance(icon_div, Tag) icon_img = icon_div.findChild('img') if icon_img is None: return None assert isinstance(icon_img, Tag) return image_store.get_image(icon_img.attrs['src']) def getByClass(css_class: str) -> Tag | None: tag = chunk_dom.findChild('div', class_=css_class) assert tag is None or isinstance(tag, Tag) return tag def stripHREF(tag: Tag) -> None: for c in tag.findChildren("a"): if "href" in c.attrs: del c.attrs["href"] def getMeta(css_class: str) -> Tag | None: tag = getByClass(css_class) if tag is None: return None stripHREF(tag) return tag content = chunk_dom.findChild('div', class_='post-content') assert isinstance(content, Tag) return Chunk(getIcon(), getMeta('post-character'), getMeta('post-screenname'), getMeta('post-author'), content) def renderChunk(texifier: Texifier, chunk: Chunk) -> bytes: return b''.join([ br'\glowhead{', br'\glowicon{%s}' % chunk.icon.encode('UTF-8') if chunk.icon else b'', b'}{', texifier.texify(chunk.character) if chunk.character else b'', b'}{', texifier.texify(chunk.screen_name) if chunk.screen_name else b'', b'}{', texifier.texify(chunk.author) if chunk.author else b'', b'}', texifier.texify(chunk.content)]) ContentOnlyLayout = br''' \newcommand{\glowhead}[4]{} ''' BelowIconLayout = br''' \newcommand{\glowhead}[4]{\wrapstuffclear \begin{wrapstuff}[l] \fbox{ \begin{varwidth}{0.5\textwidth} \smash{\parbox[t][0pt]{0pt}{ \setlength{\fboxrule}{0.2pt} \setlength{\fboxsep}{0pt} \vspace{-3.4pt} \fbox{\hspace{107mm}} }\\*} \vspace{-1em} \begin{center} #1\ifnotempty {#1}{\\*}#2\ifnotempty {#2}{\\*}#3\ifnotempty {#3}{\\*}#4 \end{center} \end{varwidth} } \end{wrapstuff} \strut \noindent}''' # Why is \textwidth not the width of the text? # Why is the width of the text .765\textwidth? BesideIconLayout = br''' \newcommand{\glowhead}[4]{ \strut \noindent\fbox{ #1 \parbox[b]{.765\textwidth}{ \begin{center} #2\ifnotempty {#2}{\\*}#3\ifnotempty {#3}{\\*}#4 \end{center} } }\\* \vspace{-0.75em}\\* \noindent}'''