<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/rss-style.xsl" type="text/xsl"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	     xmlns:dc="http://purl.org/dc/elements/1.1/"
	   xmlns:atom="http://www.w3.org/2005/Atom"
	     xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	  xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>
<channel>
	<title>python &#8211; Terence Eden’s Blog</title>
	<atom:link href="https://shkspr.mobi/blog/tag/python/feed/" rel="self" type="application/rss+xml" />
	<link>https://shkspr.mobi/blog</link>
	<description>Regular nonsense about tech and its effects 🙃</description>
	<lastBuildDate>Fri, 03 Apr 2026 09:24:56 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>hourly</sy:updatePeriod>
	<sy:updateFrequency>1</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>

<image>
	<url>https://shkspr.mobi/blog/wp-content/uploads/2023/07/cropped-avatar-32x32.jpeg</url>
	<title>python &#8211; Terence Eden’s Blog</title>
	<link>https://shkspr.mobi/blog</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title><![CDATA[Random File Format]]></title>
		<link>https://shkspr.mobi/blog/2026/04/random-file-format/</link>
					<comments>https://shkspr.mobi/blog/2026/04/random-file-format/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 01 Apr 2026 11:34:57 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[linux]]></category>
		<category><![CDATA[experiment]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=65027</guid>

					<description><![CDATA[This was an idea I had back in the days of Naptster.  At the turn of the century, it was common to listen to an &#34;acquired&#34; music file only to find it was missing a few seconds at the end due to a prematurely stopped download.  Some video formats would refuse to play at all if the moov atom at the end of the file was missing.  I wondered if it would be possible to make a file format which was…]]></description>
										<content:encoded><![CDATA[<p>This was an idea I had back in the days of Naptster.</p>

<p>At the turn of the century, it was common to listen to an "acquired" music file only to find it was missing a few seconds at the end due to a prematurely stopped download.  Some video formats would refuse to play at all if the <a href="https://www.cnwrecovery.com/manual/Fragmented3GPMP4Files.html"><code>moov</code> atom at the end of the file was missing</a>.</p>

<p>I wondered if it would be possible to make a file format which was close to impossible to read unless the <em>entire</em> file was intact. I don't mean including a checksum to detect download errors - I mean a layout which was <strong>intrinsically fragile</strong> to corruption.</p>

<p>While digging through an old backup CD, I found my original notes. I'm rather impressed at what neophyte-me had constructed.  My outline was:</p>

<ul>
<li>The file ends with a 32 bit pointer. This points to the location of the first information block.</li>
<li>The information block describes the length of the data block which follows it.</li>
<li>At the end of the data block is another 32 bit pointer. This points to the location of the next information block.</li>
<li>The start of the file may be a pointer, or it may be padded with random data.</li>
<li>There may be random data padded between the data blocks.</li>
</ul>

<p>This ensures that a file which has been only partially downloaded - whether truncated at the end or missing pieces elsewhere - cannot be successfully read.</p>

<p>Here's a worked example. Start at the end and follow the thread.</p>

<ol start="0">
<li>Random data.</li>
<li>Data block size is 2.</li>
<li>Data</li>
<li>Data</li>
<li>EOF.</li>
<li>Data block size is 1.</li>
<li>Data.</li>
<li>Go to location 1.</li>
<li>Random data.</li>
<li>Go to location 5.</li>
</ol>

<p>There are, of course, a few downsides to this idea.</p>

<p>Most prominently, it bloats file size. If the data block size was a constant 1MB, that would pad the size a negligible amount. But with variable data block size, it could increase it significantly. Random padding also increases the size.</p>

<p>If the block size is consistent and there's no random padding data, the files can be mostly reconstructed.</p>

<p>Depending on which parts of the file are missing, it may be possible to recover the majority of the file.</p>

<p>A location block size of 32 bits restricts the file-size to less than 4GB. A 64 bit pointer might be excessive or might be future-proof!</p>

<p>Highly structured files with predictable patterns, or text files, may be easy to recover large bits of information.</p>

<p>A malformed file could contain an infinite loop of pointers.</p>

<p>Perhaps a <a href="https://en.wikipedia.org/wiki/File_format#Magic_number">magic number</a> should be at the start (or end) of the file?</p>

<p>While reading the file is as simple as following the pointers, <em>constructing</em> the file is more complex, especially if blocks have variable lengths.</p>

<h2 id="code"><a href="https://shkspr.mobi/blog/2026/04/random-file-format/#code">Code</a></h2>

<p>Here's a trivial encoder. It reads a file in consistently sized chunks of 1,024 bytes. It shuffles them up and writes them to a new file. The last 4 bytes contain a pointer to the first block, which says the data length is 1,024. After that, there is a 4 byte pointer to the next block location.</p>

<pre><code class="language-python">import random

#   Size of data, headers, and pointers.
data_length = 1024
header_length  = 4
pointer_length = 4

#   Read the file into a data structure.
original_blocks = list()
with open( "test.jpg", "rb") as file:
    for data in iter( lambda: file.read( data_length ), b"" ):
        #   Add padding if length is less than the desired length.
        padding = data_length - len( data )
        data += b"\0" * padding
        original_blocks.append( data )

#   How many blocks are there?
original_length = len( original_blocks )

#   Create a random order of blocks.
order = list( range( 0, original_length ) )
random.shuffle( order )

#   Where is the start of the file?
first_block_index = order.index( 0 )
first_block_pointer = first_block_index * ( header_length + data_length + pointer_length )

#   Loop through the order and write to a new file.
i = 0
#   Open as binary file to add the pointers correctly.
with open( "output.rff", "wb" ) as output:
    while i &lt; original_length:
        #   Where are we?
        current_block = i
        current_block_value = order[i]
        #   Write length of data in little-endian 32 bytes.
        output.write( data_length.to_bytes( header_length, "little") )
        #   Write data
        output.write( original_blocks[ current_block_value ] )
        i = i+1
        #   Last block. Write an EOF header.
        if ( current_block_value + 1 &gt;= original_length ):
            eof = 4294967295
            output.write( eof.to_bytes( header_length, "little") )
        else:
            next_block = order.index( current_block_value + 1 )
            #   Write pointer to next block
            next_block_location = next_block * ( header_length + data_length + pointer_length )
            output.write( next_block_location.to_bytes( pointer_length, "little" ) )
    #   At the end of the file, write the pointer to block 0.
    output.write( first_block_pointer.to_bytes( pointer_length, "little" ) )
</code></pre>

<p>And here is a similarly trivial decoder. It reads the last 32 bits, moves to that location, reads the block size, reads the data and writes it to a new file, then reads the next pointer.</p>

<pre><code class="language-python">import os
#   Size of data, headers, and pointers.
header_length  = 4
pointer_length = 4
#   File name to write to.
decoded_file = "decoded.bin"

#   Create an empty file.
with open( decoded_file, "w") as file:
    pass

#   Function to loop through the blocks.
def read_block( position, i ):
    #   Move to the position in the file.
    input_file.seek( position, 0 )
    #   Read the data length header.
    data_length = int.from_bytes( input_file.read( header_length ), "little" )
    #   Move to the data block.
    input_file.seek( position + header_length, 0 )
    #   Read the data.
    data = input_file.read( data_length )
    #   Read the pointer header.
    next_position = int.from_bytes( input_file.read( pointer_length ), "little" )
    #   If this is the final block, it may have null padding. Remove it.
    if ( next_position == 4294967295 ) :
        data = data.rstrip(b"\0")
    #   Append the data to the decoded file.
    with open( decoded_file, "ab" ) as file:
        file.write( data )
    #   If this is the final block, finish searching.
    if ( next_position == 4294967295 ) :
         print("File decoded.")
    else:
        #   Move to the next position.
        read_block( next_position, i+1 )

#   Open the file as binary.
input_file = open( "output.rff", "rb" )

#   Read the last 4 bytes.
input_file.seek( -4, 2 )

#   Get position of first block
first_block = int.from_bytes( input_file.read(), "little" )

#   Start reading the file.
seek_to = first_block
read_block( seek_to, 0 )
</code></pre>

<p>As I said, these are both trivial. They are a bit buggy and contain some hardcoded assumptions.</p>

<p>Here are two files encoded as "RFF" - Random File Format - <a href="https://shkspr.mobi/blog/wp-content/uploads/2026/03/output.jpg.rff">an image</a> by Maria Sibylla Merian, and the <a href="https://shkspr.mobi/blog/wp-content/uploads/2026/03/output.txt.rff">text of Romeo and Juliet</a>.</p>

<p>Have fun decoding them!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=65027&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2026/04/random-file-format/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Removing "/Subtype /Watermark" images from a PDF using Linux]]></title>
		<link>https://shkspr.mobi/blog/2026/01/removing-subtype-watermark-images-from-a-pdf-using-linux/</link>
					<comments>https://shkspr.mobi/blog/2026/01/removing-subtype-watermark-images-from-a-pdf-using-linux/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 22 Jan 2026 12:34:02 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[LLM]]></category>
		<category><![CDATA[pdf]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=63035</guid>

					<description><![CDATA[Problem: I&#039;ve received a PDF which has a large &#34;watermark&#34; obscuring every page.  Investigating: Opening the PDF in LibreOffice Draw allowed me to see that the watermark was a separate image floating above the others.  Manual Solution: Hit page down, select image, delete, repeat 500 times. BORING!  Further Investigating: Using pdftk, it&#039;s possible to decompress a PDF. That makes it easier to look …]]></description>
										<content:encoded><![CDATA[<p><strong>Problem:</strong> I've received a PDF which has a large "watermark" obscuring every page.</p>

<p><strong>Investigating:</strong> Opening the PDF in LibreOffice Draw allowed me to see that the watermark was a separate image floating above the others.</p>

<p><strong>Manual Solution:</strong> Hit page down, select image, delete, repeat 500 times. BORING!</p>

<p><strong>Further Investigating:</strong> Using <a href="https://linux.die.net/man/1/pdftk">pdftk</a>, it's possible to decompress a PDF. That makes it easier to look through manually.</p>

<p><code>pdftk input.pdf output output.pdf uncompress</code></p>

<p>Hey presto! A PDF you can open in a text editor! Deep joy!</p>

<p><strong>Searching:</strong> On a hunch, I searched for "watermark" and found several lines like this:</p>

<pre><code class="language-_">&lt;&lt;
/Length 548
&gt;&gt;
stream
/Figure &lt;&lt;/MCID 0 &gt;&gt;BDC q 0 0 477 733.464 re W n q /GS0 gs 479.2799893 0 0 735.5999836 -1.0800002 -1.0559941 cm /Im0 Do Q EMC 
/Figure &lt;&lt;/MCID 1 &gt;&gt;BDC Q q 28.333 300.661 420.334 126.141 re W n q /GS0 gs 420.3339603 0 0 126.1418879 28.3330078 300.6610601 cm /Im1 Do Q EMC
/Figure &lt;&lt;/MCID 2 &gt;&gt;BDC Q q 16.106 0 444.787 215.464 re W n q /GS0 gs 444.7874274 0 0 216.5921386 16.1062775 -1.1281493 cm /Im2 Do Q EMC
/Artifact &lt;&lt;/Subtype /Watermark /Type /Pagination &gt;&gt;BDC Q q 0.7361145 0 0 0.7361145 113.3616638 240.8575745 cm /GS1 gs /Fm0 Do Q EMC
endstream
endobj
</code></pre>

<p>Those are <a href="https://opensource.adobe.com/dc-acrobat-sdk-docs/library/pdfmark/pdfmark_Logical.html">Marked Content Blocks</a>.  In <em>theory</em> you can just chop out the line with <code>/Subtype /Watermark</code> but each block has a <code>/length</code> variable - so you'd also need to adjust that to account for what you've changed - otherwise the layout goes all screwy.</p>

<p>That led me to <a href="https://github.com/pymupdf/PyMuPDF/discussions/1855">PyMuPDF which claimed to solve the problem</a>. But running that code only removed <em>some</em> of the watermarks. It got stuck on an infinite loop on certain pages.</p>

<p>So, now that I had more detailed knowledge, I managed to get an LLM to construct something which <em>mostly</em> seems to work.</p>

<p>Does it work with every PDF? I don't know. Does it contain subtle implementation bugs? Probably. Is there an easier way to do this? Not that I can find.</p>

<pre><code class="language-python">import re
import pymupdf

# Open the PDF
doc = pymupdf.open("output.pdf")

# Regex of the watermarks
pattern = re.compile(
    rb"/Artifact\s*&lt;&lt;[^&gt;]*?/Subtype\s*/Watermark[^&gt;]*?&gt;&gt;BDC.*?EMC",
    re.DOTALL
)

# Loop through the PDF's pages
for page_num, page in enumerate(doc, start=1):
    print(f"Processing page {page_num}")
    xrefs = page.get_contents()
    for xref in xrefs:
        cont = doc.xref_stream(xref)
        new_cont, n = pattern.subn(b"", cont)
        if n &gt; 0:
            print(f"  Removed {n} watermark block(s)")
            doc.update_stream(xref, new_cont)

doc.save("no-watermarks.pdf")
</code></pre>

<p>One of the (many) problems with Vibe Coding is that trying to get a LLM to spit out something useful depends <em>massively</em> on how well you know the subject area. I'm proud to say I know vanishingly little about the <a href="https://shkspr.mobi/blog/2015/11/a-polite-way-to-say-ridiculously-complicated/">baroque</a> PDF specification - which meant that most of my attempts to use various "AI" tools consisted of me saying "No, that doesn't work" and the accurs'd machine saying back "Golly-gee! You're right! Let me fix that!" and then breaking something else.</p>

<p>I'm not sure this is the future we wanted, but it looks like the future we've got.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=63035&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2026/01/removing-subtype-watermark-images-from-a-pdf-using-linux/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Improving PixelMelt's Kindle Web Deobfuscator]]></title>
		<link>https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/</link>
					<comments>https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sun, 19 Oct 2025 11:34:37 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[Amazon]]></category>
		<category><![CDATA[drm]]></category>
		<category><![CDATA[ebooks]]></category>
		<category><![CDATA[kindle]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=64017</guid>

					<description><![CDATA[A few days ago, someone called PixelMelt published a way for Amazon&#039;s customers to download their purchased books without DRM. Well… sort of.  In their post &#34;How I Reversed Amazon&#039;s Kindle Web Obfuscation Because Their App Sucked&#34; they describe the process of spoofing a web browser, downloading a bunch of JSON files, reconstructing the obfuscated SVGs used to draw individual letters, and running O…]]></description>
										<content:encoded><![CDATA[<p>A few days ago, someone called PixelMelt published a way for Amazon's customers to download their purchased books without DRM. Well… <em>sort of</em>.</p>

<p>In their post "<a href="https://blog.pixelmelt.dev/kindle-web-drm/">How I Reversed Amazon's Kindle Web Obfuscation Because Their App Sucked</a>" they describe the process of spoofing a web browser, downloading a bunch of JSON files, reconstructing the obfuscated SVGs used to draw individual letters, and running OCR on them to extract text.</p>

<p>There were a few problems with this approach.</p>

<p>Firstly, the downloader was hard-coded to only work with the .com site. That fix was simple - do a search and replace on <code>amazon.com</code> with <code>amazon.co.uk</code>. Easy!</p>

<p>But the harder problem was with the OCR. The code was designed to visually centre each extracted glyph. That gives a nice amount of whitespace around the character which makes it easier for OCR to run. The only problem is that some characters are ambiguous when centred:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/10/centred-fs8.png" alt="Several letters drawn with vertical centering." width="1134" height="177" class="aligncenter size-full wp-image-64025">

<p>When I ran the code, lots of full-stops became midpoints, commas became apostrophes, and various other characters went a bit wonky.</p>

<p>That made the output rather hard to read. This was compounded by the way line-breaks were treated. Modern eBooks are designed to be reflowable - no matter the size of your screen, lines should only break on a new paragraph. This had forced linebreaks at the end of every displayed line - rather than at the end of a paragraph.</p>

<p>So I decided to fix it.</p>

<h2 id="a-new-approach"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#a-new-approach">A New Approach</a></h2>

<p>I decided that OCRing an entire page would yield better results than single characters. I was (mostly) right.  Here's what a typical page looks like after de-obfuscation and reconstruction:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/10/sample-page.webp" alt="A page of text." width="500" height="800" class="aligncenter size-full wp-image-64027">

<p>As you can see - the typesetting is good for the body text, but skew-whiff for the title. Bold and italics are preserved. There are no links or images.</p>

<p>Here's how I did it.</p>

<h3 id="extract-the-characters"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#extract-the-characters">Extract the characters</a></h3>

<p>As in the original code, I took the SVG path of the character and rendered it as a monochrome PNG. Rather than centring the glyph, I used the height and width provided in the <code>glyphs.json</code> file. That gave me a directory full of individual letters, numbers, punctuation marks, and ligatures. These were named by fontKey (bold, italic, normal, etc).</p>

<h3 id="create-a-blank-page"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#create-a-blank-page">Create a blank page</a></h3>

<p>The <code>page_data_0_4.json</code> has a width and height of the page. I created a white PNG with the same dimensions. The individual characters could then be placed on that.</p>

<h3 id="resize-the-characters"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#resize-the-characters">Resize the characters</a></h3>

<p>In the <code>page_data_0_4.json</code> each run of text has a fontKey - which allows the correct glyph to be selected. There's also a <code>fontSize</code> parameter. Most text seems to be (the ludicrously precise) <code>19.800001</code>. If a font had a different size, I temporarily scaled the glyph in proportion to 19.8.</p>

<p>Each glyph has an associated <code>xPosition</code>, along with a <code>transform</code> which gives X and Y offsets.  That allows for indenting and other text layouts.</p>

<p>The characters were then pasted on to the blank page.</p>

<p>Once every character from that page had been extracted, resized, and placed - the page was saved as a monochrome PNG.</p>

<h3 id="ocr-the-page"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#ocr-the-page">OCR the page</a></h3>

<p><a href="https://tesseract-ocr.github.io/tessdoc/">Tesseract 5</a> is a fast, modern, and <em>reasonably</em> accurate OCR engine for Linux.</p>

<p>Running <code>tesseract page_0022.png output -l eng</code> produced a .txt file with all the text extracted.</p>

<p>For a more useful HTML style layout, the <a href="https://en.wikipedia.org/wiki/HOCR">hOCR output</a> can be used: <code>tesseract page_0022.png output -l eng hocr</code></p>

<p>Or, a PDF with embedded text: <code>tesseract page_0022.png output -l eng pdf</code></p>

<h3 id="mistakes"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#mistakes">Mistakes</a></h3>

<p>OCR isn't infallible. Even with a high resolution image and a clear font, there were some errors.</p>

<ul>
<li>Superscript numerals for footnotes were often missing from the OCR.</li>
<li>Words can run together even if they are well spaced.</li>
<li>Tesseract can recognise bold and italic characters - but it outputs everything as plain text.</li>
</ul>

<h2 id="whats-missing"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#whats-missing">What's missing?</a></h2>

<p>Images aren't downloaded. I took a brief look and, while there are links to them in the metadata, they're downloaded as encrypted blobs. I'm not clever enough to do anything with them.</p>

<p>The OCR can't pick out semantic meaning. Chapter headings and footnotes are rendered the same way as text.</p>

<p>Layout is flat. The image of the page might have an indent, but the outputted text won't.</p>

<h2 id="whats-next"><a href="https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/#whats-next">What's next?</a></h2>

<p>This is very far from perfect. It can give you a visually <em>similar</em> layout to a book you have purchased from Amazon. But it won't be reflowable.</p>

<p>The text will be <em>reasonably</em> accurate. But there will be plenty of mistakes.</p>

<p>You can get an HTML layout with hOCR. But it will be missing formatting and links.</p>

<p>Processing all the JSON files and OCRing all the images is <em>relatively</em> quick. But tweaking and assembling is still fairly manual.</p>

<p>There's nothing particularly clever about what I've done. The original code didn't come with an open source software licence, so I am unable to share my changes - but any moderately competent programmer could recreate this.</p>

<p>Personally, I've just stopped buying books from Amazon. I find that <a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/">Kobo is often cheaper</a> and their DRM is easy to bypass. But if you have many books trapped in Amazon - or a book is only published there - this is a barely adequate way to liberate it for your personal use.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=64017&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/10/improving-pixelmelts-kindle-web-deobfuscator/feed/</wfw:commentRss>
			<slash:comments>5</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Get alerted when your Kobo wishlist books drop in price]]></title>
		<link>https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/</link>
					<comments>https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 01 May 2025 11:34:06 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[ebooks]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[reading]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=59768</guid>

					<description><![CDATA[The brilliant kobodl Python package allows you to interact with your Kobo account programmatically.  You can list all the books you&#039;ve purchased, download them, and - as of version 0.12.0 - view your wishlist.  Here&#039;s a rough and ready Python script which will tell you when any the books on your wishlist have dropped below a certain amount.  Table of ContentsPrerequisitesGet your wishlistSort the …]]></description>
										<content:encoded><![CDATA[<p>The brilliant <a href="https://github.com/subdavis/kobo-book-downloader/">kobodl Python package</a> allows you to interact with your Kobo account programmatically.  You can list all the books you've purchased, download them, and - as of version 0.12.0 - view your wishlist.</p>

<p>Here's a rough and ready Python script which will tell you when any the books on your wishlist have dropped below a certain amount.</p>

<p></p><nav role="doc-toc"><menu><li><h2 id="table-of-contents"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#table-of-contents">Table of Contents</a></h2><menu><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#prerequisites">Prerequisites</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#get-your-wishlist">Get your wishlist</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#sort-the-wishlist">Sort the wishlist</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#create-the-message">Create the Message</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#send-an-email">Send an Email</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#setting-the-settings">Setting the settings</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#the-end-result">The End Result</a></li><li><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#next-steps">Next Steps</a></li></menu></li></menu></nav><p></p>

<h2 id="prerequisites"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#prerequisites">Prerequisites</a></h2>

<ol>
<li><a href="https://pypi.org/project/kobodl/">Install kobodl</a> following their guide.</li>
<li>Log in with your account by running <code>kobodl user add</code></li>
<li>Check that the configuration file is saved in the default location <code>/home/YOURUSERNAME/.config/kobodl.json</code></li>
</ol>

<h2 id="get-your-wishlist"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#get-your-wishlist">Get your wishlist</a></h2>

<p>The kobodl function <code>GetWishList()</code> takes a list of users and returns a generator. The generator contains the book's name and author. The price is a string (for example <code>5.99 GBP</code>) so needs to be split at the space.</p>

<p>Here's a quick proof of concept:</p>

<pre><code class="language-python">import kobodl
wishlist = kobodl.book.actions.GetWishList( kobodl.globals.Settings().UserList.users )
for book in wishlist:
    print( book.Title + " - "  + book.Author + " " + book.Price.split()[0] )
</code></pre>

<h2 id="sort-the-wishlist"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#sort-the-wishlist">Sort the wishlist</a></h2>

<p>Using Pandas, the data can be added to a dataframe and then sorted by price:</p>

<pre><code class="language-python">import kobodl
import pandas as pd

#   Set up the lists
items  = []
prices = []
ids    = []

wishlist = kobodl.book.actions.GetWishList( kobodl.globals.Settings().UserList.users )

for book in wishlist:
    items.append( book.Title + " - "  + book.Author )
    prices.append( float( book.Price.split()[0] ) )
    ids.append( book.RevisionId )

#   Place into a DataFrame
all_items = zip( ids, items, prices )
book_prices = pd.DataFrame( list(all_items), columns = ["ID", "Name", "Price"])
book_prices = book_prices.reset_index()  

#   Get books cheaper than three quid
cheap_df = book_prices[ book_prices["Price"] &lt; 3 ]
</code></pre>

<h2 id="create-the-message"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#create-the-message">Create the Message</a></h2>

<p>This will write the body text of the email. It gives you the price, book details, and a search link to buy the book.</p>

<pre><code class="language-python">from urllib.parse import quote_plus

#   Search Prefix
website = "https://www.kobo.com/gb/en/search?query="

#   Email Body
message = ""

for index, row in cheap_df.sort_values("Price").iterrows():
    name  = row["Name"]
    price = str(row["Price"])
    link = website + quote_plus( name )
    message += "£" + price + " - " + name + "\n" + link + "\n\n"
</code></pre>

<h2 id="send-an-email"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#send-an-email">Send an Email</a></h2>

<p>Python makes it fairly easy to send an email - assuming you have a co-operative mailhost.</p>

<pre><code class="language-python">import smtplib
from email.message import EmailMessage

#   Send Email
def send_email(message):
    email_user = 'you@example.com'
    email_password = 'P@55w0rd'
    to = 'destination@example.com'
    msg = EmailMessage()
    msg.set_content(message)
    msg['Subject'] = "Kobo price drops"
    msg['From'] = email_user
    msg['To'] = to
    server = smtplib.SMTP_SSL('example.com', 465)
    server.ehlo()
    server.login(email_user, email_password)
    server.send_message(msg)
    server.quit()

send_email( message )
</code></pre>

<h2 id="setting-the-settings"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#setting-the-settings">Setting the settings</a></h2>

<p>When running as a script, it is necessary to <a href="https://github.com/subdavis/kobo-book-downloader/issues/159">ensure the settings are correctly initialised</a>.</p>

<pre><code class="language-python">from kobodl.settings import Settings

my_settings = Settings()
kobodl.Globals.Settings = my_settings
</code></pre>

<h2 id="the-end-result"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#the-end-result">The End Result</a></h2>

<p>I have a cron job which runs this every morning.  It sends an email like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/04/books-fs8.png" alt="Screenshot of an email showing cheap books." width="370" class="aligncenter size-full wp-image-59769">

<h2 id="next-steps"><a href="https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/#next-steps">Next Steps</a></h2>

<p>Some possible ideas. If you can code these, let me know!</p>

<ul>
<li>Save the prices so it sees if there's been a drop since yesterday.</li>
<li>Compare prices to Amazon for <a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/">eBook Arbitrage</a>.</li>
<li>Automatically buy any book that hits 99p.</li>
</ul>

<p>Happy reading!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=59768&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/05/get-alerted-when-your-kobo-wishlist-books-drop-in-price/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Automatic Kobo and Kindle eBook Arbitrage]]></title>
		<link>https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/</link>
					<comments>https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 19 Feb 2025 12:34:43 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[Amazon]]></category>
		<category><![CDATA[ebooks]]></category>
		<category><![CDATA[kindle]]></category>
		<category><![CDATA[kobo]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=58241</guid>

					<description><![CDATA[This post will show you how to programmatically get the cheapest possible price on eBooks from Kobo.  Background  Amazon have decided to stop letting customers download their purchased eBooks onto their computers. That means I can&#039;t strip the DRM and read on my non-Amazon eReader.  So I guess I&#039;m not spending money with Amazon any more. I&#039;m moving to Kobo for three main reasons:   They provide…]]></description>
										<content:encoded><![CDATA[<p>This post will show you how to programmatically get the cheapest possible price on eBooks from Kobo.</p>

<h2 id="background"><a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/#background">Background</a></h2>

<p>Amazon have decided to stop letting customers download their purchased eBooks onto their computers. That means I can't strip the DRM and read on my non-Amazon eReader.</p>

<p>So I guess I'm not spending money with Amazon any more. I'm moving to Kobo for three main reasons:</p>

<ol>
<li>They provide standard ePubs for download.</li>
<li>ePub DRM is trivial to remove.</li>
<li>Kobo will <em>undercut</em> Amazon's prices!</li>
</ol>

<p>Here's the thing. I <em>want</em> to <strong>buy</strong> my eBooks. It is <a href="https://goodereader.com/blog/e-book-news/where-do-consumers-get-their-e-books-from">trivial to pirate almost any modern book</a>. But, call me crazy, I like rewarding writers with a few pennies. That said, I'm not made of money, so I want to get the best (legal) deal possible.</p>

<p><a href="https://www.kobo.com/gb/en/p/pricematch-about">Kobo do a price-match with other eBook retailers</a>. It says:</p>

<blockquote><p>We'll award a credit to your Kobo account equal to the price difference, <strong>plus 10% of the competitor’s price</strong>.</p></blockquote>

<p>I found a book I wanted which was £4.99 on Kobo. The Amazon Kindle price was £4.31.</p>

<p><code>4.99 - ( (4.99 - 4.31) + (4.31 * 0.1) ) = 3.88</code></p>

<p>I purchased the book, sent a request for a price match, and got this email a few hours later:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/02/kobo.png" alt="﻿We’re pleased to confirm that the price match you requested has been successfully processed. The credit has been applied to your Kobo account. ﻿Credit amount: £ 1.11 GBP" width="1008" height="756" class="aligncenter size-full wp-image-58242">

<p>OK! So what steps can we automate, and which will have to remain manual?</p>

<h2 id="amazon-pricing-api"><a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/#amazon-pricing-api">Amazon Pricing API</a></h2>

<p>Amazon have a <a href="https://webservices.amazon.com/paapi5/documentation/">Product Advertising API</a>. You will need to register for the <a href="https://affiliate-program.amazon.co.uk/">Amazon Affiliate Program</a> and make some qualifying sales before you get API access.</p>

<p>In order to search for an ISBN and get the price back, you need to send:</p>

<pre><code class="language-json">{
 "Keywords": "isbn:9781473613546",
 "Resources": ["Offers.Listings.Price"],
}
</code></pre>

<p>Using <a href="https://github.com/LeilaSchooley/paapi5-python-sdk">the updated Python API for PAAPI</a>:</p>

<pre><code class="language-python">from paapi5_python_sdk import DefaultApi, SearchItemsRequest, SearchItemsResource, PartnerType

def search_items():
    access_key = "ABC"
    secret_key = "123"
    partner_tag = "shkspr-21"
    host = "webservices.amazon.co.uk"
    region = "eu-west-1"

    api = DefaultApi(access_key=access_key, secret_key=secret_key, host=host, region=region)

    request = SearchItemsRequest(
        partner_tag=partner_tag,
        partner_type=PartnerType.ASSOCIATES,
        keywords="isbn:9781473613546",
        search_index="All",
        item_count=1,
        resources=["Offers.Listings.Price"]
    )

    response = api.search_items(request)

    print(response)

search_items()
</code></pre>

<p>(Add your own access key, secret key, and tag. You may need to change the host and region depending on where you are in the world.)</p>

<p>That returns something like:</p>

<pre><code class="language-json">{
    "search_result": {
        "items": [
            {
                "asin": "B09JLQHHXN",
                "detail_page_url": "https://www.amazon.co.uk/dp/B09JLQHHXN?tag=shkspr-21&amp;linkCode=osi&amp;th=1&amp;psc=1",
                "offers": {
                    "listings": [
                        {
                            "price": {
                                "amount": 2.99,
                                "currency": "GBP",
                                "display_amount": "£2.99"
                            }
                        }
                    ]
                }
            }
        ]
    }
}
</code></pre>

<p>(I've truncated the above so it only shows the relevant information.)</p>

<h2 id="kobo-isbn-price"><a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/#kobo-isbn-price">Kobo ISBN &amp; Price</a></h2>

<p>Let's get the ISBN and Price of a book on Kobo. There's no easy API to do this. But, thankfully, Kobo embeds some Schema.org metadata.</p>

<p>Look at the source code for <a href="https://www.kobo.com/gb/en/ebook/venomous-lumpsucker-1">https://www.kobo.com/gb/en/ebook/venomous-lumpsucker-1</a></p>

<pre><code class="language-html">&lt;div id="ratings-widget-details-wrapper" class="kobo-gizmo"
     data-kobo-gizmo="RatingAndReviewWidget"
     data-kobo-gizmo-config ='{&amp;quot;googleBook&amp;quot;:&amp;quot;{\r\n  \&amp;quot;@context\&amp;quot;: \&amp;quot;http://schema.org\&amp;quot;,\r\n  \&amp;quot;@type\&amp;quot;: \&amp;quot;Book\&amp;quot;,\r\n  \&amp;quot;name\&amp;quot;: \&amp;quot;Venomous Lumpsucker\&amp;quot;,\r\n  \&amp;quot;genre\&amp;quot;: [\r\n    \&amp;quot;Fiction \\u0026 Literature\&amp;quot;,\r\n    \&amp;quot;Humorous\&amp;quot;,\r\n    \&amp;quot;Literary\&amp;quot;\r\n  ],\r\n  \&amp;quot;inLanguage\&amp;quot;: \&amp;quot;en\&amp;quot;,\r\n  \&amp;quot;author\&amp;quot;: {\r\n    \&amp;quot;@type\&amp;quot;: \&amp;quot;Person\&amp;quot;,\r\n    \&amp;quot;name\&amp;quot;: \&amp;quot;Ned Beauman\&amp;quot;\r\n  },\r\n  \&amp;quot;workExample\&amp;quot;: {\r\n    \&amp;quot;@type\&amp;quot;: \&amp;quot;Book\&amp;quot;,\r\n    \&amp;quot;author\&amp;quot;: {\r\n      \&amp;quot;@type\&amp;quot;: \&amp;quot;Person\&amp;quot;,\r\n      \&amp;quot;name\&amp;quot;: \&amp;quot;Ned Beauman\&amp;quot;\r\n    },\r\n    \&amp;quot;isbn\&amp;quot;: \&amp;quot;9781473613546\&amp;quot; …'&gt;
&lt;/div&gt;
</code></pre>

<p>Getting the data from the <code>data-kobo-gizmo-config</code> is a little tricky.</p>

<ul>
<li>Using Python Requests won't work because Kobo seem to run a JS CAPTCHA to detect scraping.</li>
<li>There is a <a href="https://github.com/janeczku/calibre-web/wiki/Kobo-Integration">Calibre-Web Kobo plugin</a> but it requires you to have a physical Kobo eReader in order to get an API key.</li>
<li>The <a href="https://webservice.rakuten.co.jp/explorer/api">Rakuten API</a> is only for the Japanese store.</li>
</ul>

<p>So we have to use the <a href="https://www.selenium.dev/documentation/webdriver/">Selenium WebDriver</a> to scrape the data:</p>

<pre><code class="language-python">from selenium import webdriver
from bs4 import BeautifulSoup
import json

#   Open the web page
browser = webdriver.Firefox()
browser.get("https://www.kobo.com/gb/en/ebook/venomous-lumpsucker-1")

#   Get the source
html_source = browser.page_source

#   Soupify
soup = BeautifulSoup(html_source, 'html.parser')

#   Get the encoded JSON Schema
schema = soup.find_all(id="ratings-widget-details-wrapper")[0].get("data-kobo-gizmo-config")

#   Convert to object from JSON
parsed_data = json.loads(schema)

#   Decode the nested JSON strings
parsed_data["googleBook"] = json.loads(parsed_data["googleBook"])

#    Get ISBN and Price
price = parsed_data["googleBook"]["workExample"]["potentialAction"]["expectsAcceptanceOf"]["price"]
isbn  = parsed_data["googleBook"]["workExample"]["isbn"]
print(isbn)
print(price)
</code></pre>

<h2 id="kobo-wishlist"><a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/#kobo-wishlist">Kobo Wishlist</a></h2>

<p>OK, nearly there! Given a Kobo book URl we can get the price and ISBN, then use that ISBN to get the Kindle price.  But how do we get the Kobo book URl in the first place?</p>

<p>I'm adding all the books I want to my <a href="https://www.kobo.com/blog/how-to-use-the-kobo-wishlist-feature">Kobo Wishlist</a>.</p>

<p>Inside the Wishlist is a scrap of JavaScript which contains this JSON:</p>

<pre><code class="language-json">{
    "value": {
        "Items": [
            {
                "Title": "Venomous Lumpsucker",
                "Price": "£2.99",
                "ProductUrl": "/gb/en/ebook/venomous-lumpsucker-1",
            }
        ],
        "TotalItemCount": 11,
        "ItemCountByProductType": {
            "book": 11
        },
        "PageIndex": 1,
        "TotalNumPages": 1,
       }
}
</code></pre>

<p>(Simplified to make it easier to understand.)</p>

<p>Although there's a price, there's no ISBN, So you'll need to use the "ProductUrl" to get the ISBN and Price as above.</p>

<p>Sadly, unlike Amazon, there's no way to publicly share a wishlist. Getting the JSON requires logging in, so it's back to Selenium again!</p>

<p>This should be enough:</p>

<pre><code class="language-python">from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from bs4 import BeautifulSoup
import time

browser = webdriver.Firefox()
browser.get("https://www.kobo.com/gb/en/account/wishlist")

#       Log in
username_box = browser.find_element(By.NAME, "LogInModel.UserName")
username_box.clear()
username_box.send_keys('you@example.com')

password_box = browser.find_element(By.NAME, "LogInModel.Password")
password_box.clear()
password_box.send_keys('p455w0rd')

password_box.send_keys(Keys.RETURN)

time.sleep(5) # Wait for load and rendering
</code></pre>

<p>But the Kobo presents a CAPTCHA which prevents login.</p>

<p>There is an <em>unofficial</em> API which, sadly, <a href="https://github.com/subdavis/kobo-book-downloader/issues/121">doesn't seem to work at the moment</a>.</p>

<h2 id="next-steps"><a href="https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/#next-steps">Next Steps</a></h2>

<p>For now, I'm saving specific Kobo book URls into a file and then running a scrape once per day. Hopefully, the <a href="https://github.com/subdavis/kobo-book-downloader/">unofficial Kobo API</a> will be working again soon.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=58241&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/02/automatic-kobo-and-kindle-ebook-arbitrage/feed/</wfw:commentRss>
			<slash:comments>8</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Liberate your daily statistics from JetPack]]></title>
		<link>https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/</link>
					<comments>https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 17 Oct 2024 11:34:16 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[jetpack]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=53473</guid>

					<description><![CDATA[Because Ma.tt continues to burn all of the goodwill built up by WordPress, and JetPack have decided to charge a ridiculous sum for their statistics, I&#039;ve decided to move to a new stats provider.  But I don&#039;t want to lose all the statistics I&#039;ve built up over the years.  How do I download a day-by-day export of my JetPack stats?  Luckily, there is an API for downloading all your JetPack stats! …]]></description>
										<content:encoded><![CDATA[<p>Because Ma.tt continues to burn all of the goodwill built up by WordPress, and JetPack have decided to charge a ridiculous sum for their statistics, I've decided to move to a new stats provider.  But I don't want to lose all the statistics I've built up over the years.</p>

<p>How do I download a day-by-day export of my JetPack stats<sup id="fnref:dl"><a href="https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/#fn:dl" class="footnote-ref" title="When people ask on the official support forum, they're told to privately contact JetPack. There's a help page which shows how to download a summary. But I couldn't find anything more fine-grained…" role="doc-noteref">0</a></sup>?</p>

<p>Luckily, there is <a href="https://stats.wordpress.com/csv.php">an API for downloading all your JetPack stats</a>!</p>

<p>First, get your API key by visiting <a href="https://apikey.wordpress.com/">https://apikey.wordpress.com/</a> - it should be a 12 character string. For this example, I'm going to use 123456789012. You will need to use your own API key.</p>

<p>There is some brief documentation on that page. Here are the bits we are interested in:</p>

<pre><code class="language-_">api_key     String    A secret unique to your WordPress.com user account.
blog_uri    String    The full URL to the root directory of your blog. Including the full path.
table       String    One of views, postviews, referrers, referrers_grouped, searchterms, clicks, videoplays.
end         String    The last day of the desired time frame. Format is 'Y-m-d' (e.g. 2007-05-01) and default is UTC date.
days        Integer   The length of the desired time frame. Default is 30. "-1" means unlimited.
limit       Integer   The maximum number of records to return. Default is 100. "-1" means unlimited. If days is -1, limit is capped at 500.
format      String    The format the data is returned in, 'csv', 'xml' or 'json'. Default is 'csv'.
</code></pre>

<p>In order to get all of the statistics from a single day, the URl is:</p>

<pre><code class="language-_">https://stats.wordpress.com/csv.php?api_key=123456789012
     &amp;blog_uri=https://shkspr.mobi/blog/
     &amp;table=postviews
     &amp;end=2024-10-07
     &amp;days=1
     &amp;limit=-1
</code></pre>

<p>That gets all of the statistics from <em>one</em> specific day. The <code>limit=-1</code> means it will retrieve all the records of that day<sup id="fnref:day"><a href="https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/#fn:day" class="footnote-ref" title="The maximum number of records for a specific day in my dataset was 978." role="doc-noteref">1</a></sup>.</p>

<p>That will get you a CSV which looks like:</p>

<pre><code class="language-csv">"date","post_id","post_title","post_permalink","views"
"2024-10-09",0,"Home page","https://shkspr.mobi/blog/",59
"2024-10-09",42171,"Review: HP's smallest laser printer - M140w + Linux set up","https://shkspr.mobi/blog/2022/04/review-hps-smallest-laser-printer-m140w-linux-set-up/",7
"2024-10-09",49269,"No, Oscar Wilde did not say ""Imitation is the sincerest form of flattery that mediocrity can pay to greatness""","https://shkspr.mobi/blog/2024/01/no-oscar-wilde-did-not-say-imitation-is-the-sincerest-form-of-flattery-that-mediocrity-can-pay-to-greatness/",7
"2024-10-09",53333,"The Cleaner 🆚 Der Tatortreiniger - Series 3","https://shkspr.mobi/blog/2024/10/the-cleaner-%f0%9f%86%9a-der-tatortreiniger-series-3/",7
"2024-10-09",49943,"Solved! ""Access Point Name settings are not available for this user""","https://shkspr.mobi/blog/2024/03/solved-access-point-name-settings-are-not-available-for-this-user/",7
"2024-10-09",43690,"WhatsApp Web for Android - a reasonable compromise?","https://shkspr.mobi/blog/2022/11/whatsapp-web-for-android-a-reasonable-compromise/",5
</code></pre>

<p>You can also get a JSON file using <code>&amp;format=json</code>, although it doesn't contain the permalinks.</p>

<pre><code class="language-json">[
    {
        "date": "2024-10-09",
        "postviews": [
            {
                "post_id": 0,
                "post_title": "",
                "permalink": "",
                "views": 59
            },
            {
                "post_id": 49269,
                "post_title": "No, Oscar Wilde did not say \"Imitation is the sincerest form of flattery that mediocrity can pay to greatness\"",
                "permalink": "",
                "views": 9
            },
            {
                "post_id": 42171,
                "post_title": "Review: HP's smallest laser printer - M140w + Linux set up",
                "permalink": "",
                "views": 7
            },
</code></pre>

<p>From there, I wrote a scrap of Python to download every single date individually.</p>

<pre><code class="language-python">import requests
import datetime
import os
import json

# Directory to save the JSON files
save_dir = "jetpack_stats"
os.makedirs(save_dir, exist_ok=True)

# URL of the API
base_url = "https://stats.wordpress.com/csv.php?api_key=123456789012"+\
           "&amp;blog_uri=https://example.com/"+\
           "&amp;table=postviews"+\
           "&amp;days=1"+\
           "&amp;format=json"+\
           "&amp;limit=-1"+\
           "&amp;end="

# Make API call and save the response
def fetch_and_save_json(date):
    # Format the date as ISO8601 (YYYY-MM-DD)
    formatted_date = date.isoformat()

    # Make the API call
    url = f"{base_url}{formatted_date}"
    response = requests.get(url)

    if response.status_code == 200:
        data = response.json()
        file_name = f"{formatted_date}.json"
        file_path = os.path.join(save_dir, file_name)
        with open(file_path, "w") as f:
            json.dump(data, f, indent=4)

        print(f"Saved {formatted_date}")
    else:
        print(f"Failed! {formatted_date} status code: {response.status_code}")

# Iterate over a date range
start_date = datetime.date(2023,  1 , 1)
end_date   = datetime.date(2024, 10, 30)

# Loop through all dates 
current_date = start_date
while current_date &lt;= end_date:
    fetch_and_save_json(current_date)
    current_date += datetime.timedelta(days=1)
</code></pre>

<p>You'll need to manually find the earliest date for which your blog has statistics.</p>

<p>Running the code is a little slow. Expect about 3 minutes per year of data. I'm sure you could parallelise it if you really needed to.</p>

<p>Now, for my next trick, how do I import these data into a <em>new</em> stats plugin? That's tomorrow's blog post!</p>

<div id="footnotes" role="doc-endnotes">
<hr>
<ol start="0">

<li id="fn:dl">
<p>When people ask on the official support forum, they're <a href="https://wordpress.org/support/topic/how-do-we-export-all-stats-data-held-on-jetpack-servers/">told to privately contact JetPack</a>. There's a help page which shows <a href="https://wordpress.com/support/stats/#download-stats">how to download a summary</a>. But I couldn't find anything more fine-grained than that.&nbsp;<a href="https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/#fnref:dl" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

<li id="fn:day">
<p>The maximum number of records for a specific day in my dataset was 978.&nbsp;<a href="https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/#fnref:day" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

</ol>
</div>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=53473&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/10/liberate-your-daily-statistics-from-jetpack/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Is "Dollar Cost Averaging" a Bad Idea?]]></title>
		<link>https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/</link>
					<comments>https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 15 Aug 2024 11:34:50 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[finance]]></category>
		<category><![CDATA[money]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[statistics]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=51225</guid>

					<description><![CDATA[It&#039;s sometimes useful to run experiments yourself, isn&#039;t it?  New investors are often told that, when investing for the long term rather than chasing individual stocks, it is better to be invested for the longest possible time rather than trying to do &#34;dollar cost averaging&#34;.  DCA is the process of spreading out over time the purchasing of your investments. That way, you don&#039;t lose it all if the…]]></description>
										<content:encoded><![CDATA[<p>It's sometimes useful to run experiments yourself, isn't it?</p>

<p>New investors are often told that, when investing for the long term rather than chasing individual stocks, it is better to be invested for the longest possible time rather than trying to do "dollar cost averaging".  DCA is the process of spreading out over time the purchasing of your investments. That way, you don't lose it all if the market drops the day after you invest.</p>

<p>Let me explain...</p>

<p>Imagine that it is 1994 and your rich uncle, Scrooge McDuck, has decided to gift you $1,200 per year. How generous!</p>

<p>He has stipulated that you must invest it in the S&amp;P 500 - that's the top 500 companies in the world<sup id="fnref:fin"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#fn:fin" class="footnote-ref" title="OK, it is a bit more complicated than that. This blog is not financial advice, OK?" role="doc-noteref">0</a></sup>.</p>

<p>He gives you two choices:</p>

<ul>
<li>Put $1,200 in on the 1st of January every year.</li>
<li>Put $100 in on the 1st of the month every year.</li>
</ul>

<p>How much money do you make in each scenario?</p>

<h2 id="get-the-data"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#get-the-data">Get The Data</a></h2>

<p>Kaggle has a download for <a href="https://www.kaggle.com/datasets/gkitchen/s-and-p-500-spy">the historic S&amp;P 500 data</a>. It goes from 1993 to 2024.</p>

<p>The data looks like this:</p>

<table>
<thead>
<tr>
  <th>Date</th>
  <th align="right">Open</th>
  <th align="right">High</th>
  <th align="right">Low</th>
  <th align="right">Close</th>
  <th align="right">Volume</th>
  <th align="right">Day</th>
  <th align="right">Weekday</th>
  <th align="right">Week</th>
  <th align="right">Month</th>
  <th align="right">Year</th>
</tr>
</thead>
<tbody>
<tr>
  <td>29/01/93</td>
  <td align="right">24.70</td>
  <td align="right">24.70</td>
  <td align="right">24.58</td>
  <td align="right">24.68</td>
  <td align="right">1003200</td>
  <td align="right">29</td>
  <td align="right">4</td>
  <td align="right">4</td>
  <td align="right">1</td>
  <td align="right">1993</td>
</tr>
<tr>
  <td>01/02/93</td>
  <td align="right">24.70</td>
  <td align="right">24.86</td>
  <td align="right">24.70</td>
  <td align="right">24.86</td>
  <td align="right">480500</td>
  <td align="right">1</td>
  <td align="right">0</td>
  <td align="right">5</td>
  <td align="right">2</td>
  <td align="right">1993</td>
</tr>
<tr>
  <td>02/02/93</td>
  <td align="right">24.84</td>
  <td align="right">24.93</td>
  <td align="right">24.79</td>
  <td align="right">24.91</td>
  <td align="right">201300</td>
  <td align="right">2</td>
  <td align="right">1</td>
  <td align="right">5</td>
  <td align="right">2</td>
  <td align="right">1993</td>
</tr>
<tr>
  <td>03/02/93</td>
  <td align="right">24.95</td>
  <td align="right">25.19</td>
  <td align="right">24.93</td>
  <td align="right">25.18</td>
  <td align="right">529400</td>
  <td align="right">3</td>
  <td align="right">2</td>
  <td align="right">5</td>
  <td align="right">2</td>
  <td align="right">1993</td>
</tr>
</tbody>
</table>

<h2 id="experiment-1-time-in-the-market"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#experiment-1-time-in-the-market">Experiment 1 - Time In The Market</a></h2>

<p>Here's the algorithm we want to run.</p>

<ol>
<li>Start in 1994</li>
<li>Set the investment as 1200</li>
<li>Get the Opening price of the first entry of the year</li>
<li>Get the Closing price of the last entry of the year</li>
<li>Calculate the percentage difference</li>
<li>Multiply the investment by the growth / fall</li>
<li>Add 1200 to the investment</li>
<li>Repeat from (3) for the next year.</li>
</ol>

<p>Here's the code. I've made some assumptions - for example there are no trading fees, you buy at the opening price, and fractional dollars disappear.  I'm aware this doesn't track perfectly but it isn't intended to; this is a rough and ready reckoner.</p>

<details>
<summary>Open for Python Code</summary>

<pre><code class="language-python">import numpy as np
import locale

#   Set for American currency
locale.setlocale( locale.LC_ALL, 'en_US.UTF-8' )

#   Location of the data
file_path = 'data.csv'

#   Load CSV into a NumPy array
data = np.genfromtxt(file_path, delimiter=',', names=True, dtype=None, encoding='utf-8')

# Count the number of unique years
unique_years = 0

#   Total investment
total_investment = 0

#   Yearly investment
yearly_cash = 1200

#   Start with an opening of 0
opening = 0

#   Loop through the years
for year in range( 1994, 2024 ):
    #   Add our yearly amount
    total_investment += yearly_cash

    #   Get data for the current year
    data_year = data[data['Year'] == year]

    #   Opening Price
    if ( 0 == opening ) :
        opening = data_year['Open'][0]

    #   Closing Price
    closing = data_year['Close'][-1]

    #   Percentage Change
    change = ( closing - opening) / opening
    movement = "✅ +" if change &gt; 0 else "❌ -"

    #   Calculate change in investment
    total_investment = int( (1 + change) * total_investment )

    #   Print the running total
    print ( f"{movement}{abs(change*100):05.2f}% {year} Total investment is " + locale.currency( total_investment, grouping=True ) )

    #   Set the new opening price
    opening = closing

    #   Increment the number of years seen
    unique_years += 1

print ( f"After {unique_years} years you have invested {locale.currency( yearly_cash * unique_years , grouping=True )}" )
print ( f"Your total amount is {locale.currency( total_investment, grouping=True )}" )
</code></pre>

</details>

<p>It spits out:</p>

<blockquote><p>After 30 years you have invested $36,000</p>

<p>Your total amount is <strong>$203,445</strong></p></blockquote>

<h2 id="experiment-2-dollar-cost-averaging"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#experiment-2-dollar-cost-averaging">Experiment 2 - Dollar Cost Averaging</a></h2>

<p>OK! Can we beat that with DCA? In this scenario you take your uncle's money and invest 1/12th of it on the first trading day of every month.</p>

<details>
<summary>Open for Python Code</summary>

<pre><code class="language-python">import numpy as np
import locale

#   Set for American currency
locale.setlocale( locale.LC_ALL, 'en_US.UTF-8' )

#   Location of the data
file_path = 'data.csv'

#   Load CSV into a NumPy array
data = np.genfromtxt(file_path, delimiter=',', names=True, dtype=None, encoding='utf-8')

#   Total investment
total_investment = 0

#   Monthly investment
monthly_cash = 100

#   Number of months run
number_of_months = 0

#   Start with an opening of 0
opening = 0

#   Loop through the years
for year in range( 1994, 2024 ):

    #   Get data for the current year
    data_year = data[data['Year'] == year]

    #   There should be 12 months in every year, but let's double check!
    unique_months = np.unique(data_year['Month'])

    #   Loop through the months
    for month in unique_months:
        #   Add our monthly amount
        total_investment += monthly_cash

        # Filter data for the current month
        data_month = data_year[data_year['Month'] == month]

        #   Opening Price
        if ( 0 == opening ) :
            opening = data_month['Open'][0]

        #   Closing Price
        closing = data_month['Close'][-1]

        #   Percentage Change
        change = ( closing - opening) / opening

        movement = "✅ +" if change &gt; 0 else "❌ -"

        #   Calculate change in investment
        total_investment = int( (1 + change) * total_investment )

        #   Print the running monthly total
        print ( f"{movement}{abs(change*100):05.2f}% {year}/{month:02.0f} Total investment is " + locale.currency( total_investment, grouping=True ) )

        #   Set the new opening price
        opening = closing

        #   Increment the number of months
        number_of_months += 1
    #   Yearly total
    #print ( f"{movement}{abs(change*100):05.2f}% {year}/{month:02.0f} Total investment is " + locale.currency( total_investment, grouping=True ) )

print ( f"Your total amount is {locale.currency( total_investment, grouping=True )} after {number_of_months} months" )
</code></pre>

</details>

<p>The end result?</p>

<blockquote><p>Your total amount is <strong>$193,891</strong> after 360 months</p></blockquote>

<p>That's $9,554 <em>worse</em> than shoving all the money in on January 1st.</p>

<p>Which... Look, $10k is $10k. I'd rather have it than not. But, in the context of these numbers, it doesn't <em>feel</em> significant. Does it?</p>

<h2 id="lets-graph-it"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#lets-graph-it">Let's Graph It!</a></h2>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/07/dcalump-fs8.png" alt="Graph showing two gradually diverting lines." width="1006" height="644" class="aligncenter size-full wp-image-51229">

<h2 id="conclusion"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#conclusion">Conclusion</a></h2>

<p>Dollar Cost Averaging is <em>fine</em><sup id="fnref2:fin"><a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#fn:fin" class="footnote-ref" title="OK, it is a bit more complicated than that. This blog is not financial advice, OK?" role="doc-noteref">0</a></sup>. Over a long enough time you're probably <em>marginally</em> better off with lump-sum investing. But there's not much in it.</p>

<p>I'm sure you can construct scenarios where DCA is slightly preferential, and not every investment tracks the S&amp;P500, and I'm sure my maths might be a little wonky, and <em>obviously</em> this is not financial advice. But, yeah, DCA if you want to; the difference in this example appears to be minimal.</p>

<div id="footnotes" role="doc-endnotes">
<hr>
<ol start="0">

<li id="fn:fin">
<p>OK, it is a bit more complicated than that. This blog is not financial advice, OK?&nbsp;<a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#fnref:fin" class="footnote-backref" role="doc-backlink">↩︎</a> <a href="https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/#fnref2:fin" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

</ol>
</div>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=51225&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/08/is-dollar-cost-averaging-a-bad-idea/feed/</wfw:commentRss>
			<slash:comments>5</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Drawing PPM images on the Tildagon in MicroPython]]></title>
		<link>https://shkspr.mobi/blog/2024/06/drawing-ppm-images-on-the-tildagon-in-micropython/</link>
					<comments>https://shkspr.mobi/blog/2024/06/drawing-ppm-images-on-the-tildagon-in-micropython/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 19 Jun 2024 11:34:15 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[emfcamp]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[tildagon]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=50872</guid>

					<description><![CDATA[The Tildagon has 2MB of RAM. That&#039;s not enough to do... well, most things you&#039;d want to do with a computer!  There&#039;s not much processing power, so running complex image decoding algorithms might be a bit beyond it.  Is there a simple image format which can be parsed and displayed? Yes! The ancient Portable PixMap (PPM) format.    The standard is beautiful in its simplicity.  Here&#039;s the header: …]]></description>
										<content:encoded><![CDATA[<p>The Tildagon has 2MB of RAM. That's not enough to do... well, most things you'd want to do with a computer!  There's not much processing power, so running complex image decoding algorithms might be a bit beyond it.</p>

<p>Is there a simple image format which can be parsed and displayed? Yes! The ancient <a href="https://en.wikipedia.org/wiki/Netpbm">Portable PixMap</a> (PPM) format.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/06/tildagonimages.jpg" alt="Various circuit boards showing images." width="512" height="517" class="aligncenter size-full wp-image-50873">

<p>The standard is beautiful in its simplicity.  Here's the header:</p>

<pre><code class="language-ppm">P6
# Created by GIMP version 2.10.38 PNM plug-in
120 120
255
���t�{...
</code></pre>

<p>The <code>P6</code> identifies it as a PPM file. The <code>#</code> is a comment. <code>120 120</code> says that the image's dimensions are 120 pixels horizontal, and 120 vertical. <code>255</code> is the maximum value for each colour.</p>

<p>Then comes a big blob of binary data. Each byte is a value from 0 to 255.</p>

<p>To find the Red, Green, and Blue values of the first pixel, read the first 3 bytes. The next 3 bytes are the RGB of the next pixel. And so on.</p>

<p>There's no compression. Just pure pixel values.</p>

<p>Because of the low memory limits of the Tildagon, I found it impossible to load the entire file into memory and then paint it on the screen.  Instead, I read it in chunks.</p>

<p>First, load the file as a read-only binary. Then skip the header and get straight to the pixel data.</p>

<pre><code class="language-python">#   Open a 120px x 120px Raw / Binary PPM file
with open('/apps/ppm/chrome120.ppm', 'rb') as ppm_file:
    print("Skipping Header")
    # Skip the header
    header = b''
    while True:
        line = ppm_file.readline()
        header += line
        if header.endswith(b'\n255\n'):
            break
</code></pre>

<p>Images on the Tildagon are drawn from the top left, which has co-ordinates -120,-120</p>

<pre><code class="language-python">    #   Start at the top left
    x, y = -120, -120
</code></pre>

<p>Next, read in a line of pixels. The image is 120px wide, each pixel has 3 values, so that's 360 bytes. Grab the pixel values and draw them to screen:</p>

<pre><code class="language-python">    while True:
        #   Read in 1 line at a time (3 bytes * 120px)
        chunk = ppm_file.read(360)
        if not chunk:
            break  # End of file
        #   Read the RGB, convert to float
        for i in range(0, len(chunk), 3):
            r = chunk[i]    /255
            g = chunk[i + 1]/255
            b = chunk[i + 2]/255
            #   Draw the pixel in a 2x2 square
            ctx.rgb(r, g, b).rectangle(x, y, 2, 2).fill()
</code></pre>

<p>The screen's resolution is 240x240, so each pixel from the 120x120 image needs to be drawn as 2x2 rectangle.</p>

<p>Once that's done, move to the next square to be drawn. Once a full line has been drawn, move down to drawing the next line.</p>

<pre><code class="language-python">            #   Move the the next square
            x += 2
            #   If a complete line has been drawn
            #   Move down a line (2px) and reset the x coordinate
            if x &gt;= 120:
                x = -120
                y += 2
        #   Clear the chunk from memory
        del chunk
        #   Perform garbage collection
        gc.collect()
</code></pre>

<p>A bit of manual garbage collection doesn't hurt! And then a bit more for good luck!</p>

<pre><code class="language-python">del(ppm_file)
#   Final collection
print("Collecting")
gc.collect()
</code></pre>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=50872&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/06/drawing-ppm-images-on-the-tildagon-in-micropython/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Setting the time on the Tildagon]]></title>
		<link>https://shkspr.mobi/blog/2024/06/setting-the-time-on-the-tildagon/</link>
					<comments>https://shkspr.mobi/blog/2024/06/setting-the-time-on-the-tildagon/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Mon, 17 Jun 2024 11:34:22 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[emf]]></category>
		<category><![CDATA[emfcamp]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[tildagon]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=50843</guid>

					<description><![CDATA[I&#039;m beginning my adventures in MicroPython in the hope that I&#039;ll have something interesting working on the Tildagon Badge for EMF2026.  Here&#039;s a basic implementation of a clockface.    Here&#039;s how to set the time on the badge. There&#039;s a hardware clock which should keep time between reboots.   Install mpremote on your computer. Connect the Tildagon to your computer using a USB-C data cable On your…]]></description>
										<content:encoded><![CDATA[<p>I'm beginning my adventures in MicroPython in the hope that I'll have something interesting working on the Tildagon Badge for EMF2026.  Here's a basic implementation of a clockface.</p>

<p><video width="854" height="480" src="https://shkspr.mobi/blog/wp-content/uploads/2024/06/Watch-Timer.mp4" autoplay="" loop="" muted=""></video></p>

<p>Here's how to set the time on the badge. There's a hardware clock which <em>should</em> keep time between reboots.</p>

<ol>
<li>Install <a href="https://docs.micropython.org/en/latest/reference/mpremote.html">mpremote</a> on your computer.</li>
<li>Connect the Tildagon to your computer using a USB-C data cable</li>
<li>On your computer's command line, run <code>mpremote</code>. You should see:
&gt; <code>Connected to MicroPython at /dev/ttyACM0</code>
&gt; <code>Use Ctrl-] or Ctrl-x to exit this shell</code></li>
<li>Hold down the <kbd>ctrl</kbd> key on your computer. While holding it down, press the <kbd>C</kbd> key on your computer. This will open up a shell for you to enter commands.</li>
<li>Enter the following commands one at a time, followed by enter</li>
</ol>

<pre><code class="language-python">from machine import RTC
rtc = RTC()
rtc.datetime()
</code></pre>

<p>That will display the time that the badge currently thinks it is.</p>

<p>For example: <code>(2000, 1, 1, 5, 0, 1, 47, 984022)</code></p>

<p>This is a slightly unusual format. It is: year, month, day, weekday, hours, minutes, seconds, subseconds.</p>

<p>The "weekday" is 0 for Monday, 1 for Tuesday etc.</p>

<p>This is an array. So, to access individual elements of the time, you can say:</p>

<pre><code class="language-python">year = rtc.datetime()[0]
</code></pre>

<p>Alternatively, you can do:</p>

<pre><code class="language-python">import time
time.localtime()
</code></pre>

<p>That will return something like: <code>(2024, 6, 11, 15, 11, 39, 1, 163)</code></p>

<p>Which, <a href="https://docs.micropython.org/en/latest/library/time.html#time.gmtime">according to the documentation</a>, is "year, month, mday, hour, minute, second, weekday, yearday".</p>

<h2 id="setting-the-clock"><a href="https://shkspr.mobi/blog/2024/06/setting-the-time-on-the-tildagon/#setting-the-clock">Setting the clock</a></h2>

<p>To manually set the date and time, run:</p>

<pre><code class="language-python">rtc.datetime((2023, 6, 16, 1, 15, 36, 0, 0))
</code></pre>

<h2 id="ntp"><a href="https://shkspr.mobi/blog/2024/06/setting-the-time-on-the-tildagon/#ntp">NTP</a></h2>

<p>If you want to use NTP to synchronise the time with an Internet-based atomic clock, here's what you need to do.</p>

<ol>
<li><a href="https://tildagon.badge.emfcamp.org/using-the-badge/connect-to-wifi/">Connect your badge to WiFi</a>.</li>
<li>As above, connect with USB and run <code>mpremote</code>, then obtain a shell.</li>
<li>Enter the following commands one at a time, followed by enter:</li>
</ol>

<pre><code class="language-python">import ntptime
ntptime.settime()
rtc.datetime()
</code></pre>

<p>That will now show you the synchronised time.  Note, it will be set to <strong>UTC</strong>.  There's no way to set the timezone - you'll have to deal with that in your code elsewhere.</p>

<p>You can also <a href="https://docs.micropython.org/en/latest/esp8266/quickref.html#real-time-clock-rtc">read the reference documentation about the Real Time Clock</a>.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=50843&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/06/setting-the-time-on-the-tildagon/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Displaying a QR code in MicroPython on the Tildagon Badge]]></title>
		<link>https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/</link>
					<comments>https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sat, 15 Jun 2024 11:34:16 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[emf]]></category>
		<category><![CDATA[emfcamp]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[QR Codes]]></category>
		<category><![CDATA[tildagon]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=50822</guid>

					<description><![CDATA[This was a bit of a labour of love - and something I wanted to get running during EMF Camp. I&#039;m documenting in the hope it&#039;ll be useful for EMF 2026!  Here&#039;s the end result:    Background  I&#039;m going to assume that you have updated your badge to the latest firmware version.  You will also need to install mpremote on your development machine.  You should also have successfully run the basic Hello,…]]></description>
										<content:encoded><![CDATA[<p>This was a bit of a labour of love - and something I wanted to get running during EMF Camp. I'm documenting in the hope it'll be useful for EMF 2026!</p>

<p>Here's the end result:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/06/Hex-Badge-QR.jpg" alt="A hexagonal circuit board with a circular screen. The screen displays a monochrome QR code." width="1024" height="771" class="aligncenter size-full wp-image-50828">

<h2 id="background"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#background">Background</a></h2>

<p>I'm going to assume that you have updated your badge to the latest firmware version.</p>

<p>You will also need to <a href="https://docs.micropython.org/en/latest/reference/mpremote.html">install <code>mpremote</code></a> on your development machine.</p>

<p>You should also have successfully run the basic <a href="https://tildagon.badge.emfcamp.org/tildagon-apps/development/">Hello, World!</a> app.</p>

<h2 id="drawing-surface"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#drawing-surface">Drawing surface</a></h2>

<p>The Tildagon screen is 240x240 pixels. However, it is also a circle.  This gives an internal square of 170x170 pixels.  The drawing co-ordinates have 0,0 in the centre. Which means the target area is the red square as shown here:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/06/target-size.png" alt="A red square with co-ordinates displayed over it." width="240" height="240" class="aligncenter size-full wp-image-50823" style="border-radius: unset !important; border: none !important;">

<h2 id="generate-a-qr-code"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#generate-a-qr-code">Generate a QR code</a></h2>

<p>As you can see, there isn't much space here. A <a href="https://www.qrcode.com/en/about/version.html">Version 1 QR Code</a> is a mere 21x21 pixels. When set to "Low" error correction, it can contain up to 25 characters.  A URl should start with https:// - which is 8 characters.  That leaves 17 characters for the domain and path.</p>

<p>Use your favourite QR generator to make the tiniest QR code you can.  Make sure there's no border. It should be 21x21 pixels. Here's mine:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/06/21tinyqr.png" alt="A very small QR code." width="21" height="21" class="aligncenter size-full wp-image-50824" style="border-radius: unset !important; border: none !important;" image-rendering:="" pixelated;="">

<p>See? Tiny!</p>

<h2 id="prepare-the-qr-code"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#prepare-the-qr-code">Prepare the QR code</a></h2>

<p>Next, we need to turn the QR code into a binary matrix. There may be easier ways to do this, but I used a scrap of Python:</p>

<pre><code class="language-python">from PIL import Image
import numpy as np

#    Load the image
image = Image.open("qr.png")

#    Convert the image to grayscale
gray_image = image.convert("L")

#    Threshold the image to get binary black and white image
threshold = 128
bw_image = gray_image.point(lambda x: 0 if x &gt; threshold else 1, '1')

#    Convert the image to a NumPy array
pixel_array = np.array(bw_image, dtype=int)

#    Convert the array to a string with commas between the elements
array_str = np.array2string(pixel_array, separator=',', formatter={'int':lambda x: str(x)})

print(array_str)
</code></pre>

<p>Copy the output - we'll need it later!</p>

<h2 id="calculate-size"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#calculate-size">Calculate size</a></h2>

<p>We have a canvas of 170 pixels and a QR code of 21 pixels.  170 / 21 = 8.1 pixels. Ah. Drawing fractional pixels isn't fun. Luckily, QR codes benefit from having a safe area around them.  If we make each QR pixel 7 screen pixels, that gives us (21 x 7) = 147 pixels. Which gives us enough space for a small white border.</p>

<p>If the QR code is to be centred, the top left corner will be in position (147 / 2) = 74.  That means it will need to be drawn at position -74,-74.  The top left corner is -120,-120.</p>

<p>So the offset used to calculate the location is (-120 + 74) = 46.</p>

<p>(You might be able to get away with 8 pixels and an offset of 36 pixel. Try it!)</p>

<p>Remember those numbers!</p>

<h2 id="write-the-app"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#write-the-app">Write the app</a></h2>

<p>This reuses a lot of the Hello World code.</p>

<pre><code class="language-python">import app
from app_components import TextDialog, clear_background
from events.input import Buttons, BUTTON_TYPES

class QrApp(app.App):
    #   Define the colours
    black = (  0,   0,   0)
    white = (255, 255, 255)

    def __init__(self):
        self.button_states = Buttons(self)

    def update(self, delta):
        if self.button_states.get(BUTTON_TYPES["CANCEL"]):
            self.button_states.clear()
            self.minimise()

    def draw(self, ctx):
        clear_background(ctx)

        #   QR code data (21x21 matrix)
        qr_code =[[1,1,1,1,1,1,1,0,0,1,1,0,0,0,1,1,1,1,1,1,1],
                  [1,0,0,0,0,0,1,0,1,0,1,0,0,0,1,0,0,0,0,0,1],
                  [1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1],
                  [1,0,1,1,1,0,1,0,0,1,1,0,0,0,1,0,1,1,1,0,1],
                  [1,0,1,1,1,0,1,0,1,1,1,1,0,0,1,0,1,1,1,0,1],
                  [1,0,0,0,0,0,1,0,1,1,0,1,0,0,1,0,0,0,0,0,1],
                  [1,1,1,1,1,1,1,0,1,0,1,0,1,0,1,1,1,1,1,1,1],
                  [0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0],
                  [1,1,0,1,0,0,1,1,0,0,1,0,0,0,1,1,1,0,1,1,0],
                  [1,0,1,1,1,1,0,1,1,0,0,1,1,0,1,1,1,0,0,0,1],
                  [1,0,1,0,0,1,1,1,0,1,1,0,1,0,0,0,0,0,1,0,1],
                  [1,1,1,1,0,1,0,1,0,0,0,0,0,0,1,0,1,1,0,1,1],
                  [1,0,1,0,0,1,1,1,0,0,1,1,1,0,0,1,0,1,0,0,0],
                  [0,0,0,0,0,0,0,0,1,0,0,0,1,1,0,1,0,0,0,0,1],
                  [1,1,1,1,1,1,1,0,1,0,1,0,1,1,0,0,1,1,1,1,0],
                  [1,0,0,0,0,0,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0],
                  [1,0,1,1,1,0,1,0,0,0,1,1,0,1,0,0,1,1,0,1,1],
                  [1,0,1,1,1,0,1,0,1,0,0,0,1,0,1,0,1,0,0,0,1],
                  [1,0,1,1,1,0,1,0,0,0,1,1,1,0,1,0,1,0,1,0,1],
                  [1,0,0,0,0,0,1,0,1,1,1,0,0,1,0,0,0,0,0,0,0],
                  [1,1,1,1,1,1,1,0,1,0,1,0,1,0,1,0,1,0,0,1,0]]

        #   Draw background
        ctx.rgb(*self.white).rectangle(-120, -120, 240, 240).fill()

        #   Size of each QR code pixel on the canvas
        pixel_size = 7

        #   Offset size in pixels
        offset_size = 46

        #   Calculate the offset to start drawing the QR code (centre it within the available space)
        offset = -120 + offset_size

        #   Loop through the array
        for row in range(21):
            for col in range(21):
                if qr_code[row][col] == 1:
                    x = (col * pixel_size) + offset
                    y = (row * pixel_size) + offset
                    ctx.rgb(*self.black).rectangle(x, y, pixel_size, pixel_size).fill()

__app_export__ = QrApp
</code></pre>

<h2 id="installation"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#installation">Installation</a></h2>

<ul>
<li><a href="https://tildagon.badge.emfcamp.org/tildagon-apps/run-on-badge/">Follow the instructions</a></li>
<li>Run <code>mpremote cp ~/Documents/badge/* :/apps/qr/</code></li>
<li>Restart the badge</li>
<li>Scroll down the app list and launch the QR app</li>
</ul>

<h2 id="the-non-stupid-way"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#the-non-stupid-way">The non-stupid way!</a></h2>

<p>OK, that was the hard way - here's the easy way.</p>

<p>Use the MicroPython QR Generation library <a href="https://github.com/JASchilz/uQR/blob/master/uQR.py">uQR</a>.</p>

<p>If you pop that file in your project directory, and upload it to the badge, then you can import it with:</p>

<pre><code class="language-python">from .uQR import QRCode
</code></pre>

<p>The QR code has its own white margin and is a 2D array of True &amp; Falses.</p>

<pre><code class="language-python"># QR code data (29x29 matrix)
qr = QRCode()
qr.add_data("https://edent.tel")
qr_code = qr.get_matrix()
qr_size = len( qr_code )

#   Draw background
ctx.rgb(*self.white).rectangle(-120, -120, 240, 240).fill()

#   Size of each QR code pixel on the canvas
pixel_size = int( 170 / qr_size ) + 1

#   Border size in pixels
border_size = ( 240 - (pixel_size*qr_size) ) / 2

#   Calculate the offset to start drawing the QR code (centre it within the available space)
offset = -120 + border_size

#   Loop through the array
for row in range( len(qr_code) ):
    for col in range( len(qr_code) ):
        if qr_code[row][col] == True:
            x = (col * pixel_size) + offset
            y = (row * pixel_size) + offset
            ctx.rgb(*self.black).rectangle(x, y, pixel_size, pixel_size).fill()
</code></pre>

<h2 id="next-steps"><a href="https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/#next-steps">Next steps</a></h2>

<ul>
<li>This is hardcoded for a single QR code - mine! Perhaps it should be configurable?</li>
<li>Add some text to the screen?</li>
<li>Animations? Colour? Flashing LEDs?</li>
</ul>

<p>Got any thoughts? Stick them in the box!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=50822&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/06/displaying-a-qr-code-in-micropython-on-the-tildagon-badge/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Untappd to Mastodon - Updated!]]></title>
		<link>https://shkspr.mobi/blog/2024/05/untappd-to-mastodon-updated/</link>
					<comments>https://shkspr.mobi/blog/2024/05/untappd-to-mastodon-updated/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sun, 12 May 2024 11:34:19 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[MastodonAPI]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[untappd]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=50507</guid>

					<description><![CDATA[A few years ago, I wrote some code to post Untappd check-ins to Mastodon.  I&#039;ve recently updated it to also post a photo of the beer you&#039;re enjoying.      First up, you&#039;ll need a file called config.py to hold all your API keys:  instance = &#34;https://mastodon.social&#34; access_token          = &#34;…&#34; write_access_token    = &#34;…&#34; untappd_client_id     = &#34;…&#34; untappd_client_secret = &#34;…&#34;   Then a file called u…]]></description>
										<content:encoded><![CDATA[<p>A few years ago, I wrote some code to <a href="https://shkspr.mobi/blog/2023/03/posting-untappd-checkins-to-mastodon-and-other-services/">post Untappd check-ins to Mastodon</a>.  I've recently updated it to also post a photo of the beer you're enjoying.</p>

<iframe src="https://mastodon.social/@Edent/111784153251146118/embed" class="mastodon-embed" style="max-width: 100%; border: 0" width="400" height="600" allowfullscreen="allowfullscreen"></iframe>

<script src="https://mastodon.social/embed.js" async="async"></script>

<p>First up, you'll need a file called <code>config.py</code> to hold all your API keys:</p>

<pre><code class="language-python">instance = "https://mastodon.social"
access_token          = "…"
write_access_token    = "…"
untappd_client_id     = "…"
untappd_client_secret = "…"
</code></pre>

<p>Then a file called <code>untappd2mastodon.py</code> to do the job of grabbing your data, finding your latest check-in, then posting it to the Fediverse:</p>

<pre><code class="language-python">#!/usr/bin/env python
# -*- coding: utf-8 -*-

from mastodon import Mastodon
import json
import requests
import config

#  Set up access
mastodon = Mastodon( api_base_url=config.instance, access_token=config.write_access_token )

#       Untappd API
untappd_api_url = 'https://api.untappd.com/v4/user/checkins/edent?client_id=' + config.untappd_client_id + '&amp;client_secret='+ config.untappd_client_secret

r = requests.get(untappd_api_url)

untappd_data = r.json()

#       Latest checkin object
checkin = untappd_data["response"]["checkins"]["items"][0]
untappd_id = checkin["checkin_id"]

#       Was this ID the last one we saw?
check_file = open("untappd_last", "r")
last_id = int( check_file.read() )
print("Found " + str(last_id) )
check_file.close()

if (last_id != untappd_id ) :
        print("Found new checkin")
        check_file = open("untappd_last", "w")
        check_file.write( str(untappd_id) )
        check_file.close()
        #       Start creating the message
        message = ""

        if "checkin_comment" in checkin :
                message += checkin["checkin_comment"]

        if "beer" in checkin :
                message += "\nDrinking: " + checkin["beer"]["beer_name"]

        if "brewery" in checkin :
                message += "\nBy: "       + checkin["brewery"]["brewery_name"]

        if "venue" in checkin :
                if "venue_name" in checkin["venue"] :
                        message += "\nAt: "       + checkin["venue"]["venue_name"]
        #       Scores etc
        untappd_checkin_url = "https://untappd.com/user/edent/checkin/" + str(untappd_id)
        untappd_rating      = checkin["rating_score"]
        untappd_score       = "🍺" * int(untappd_rating)

        message += "\n" +  untappd_score + "\n" + untappd_checkin_url + "\n" + "#untappd"

        #       Get Image
        if checkin["media"]["count"] &gt; 0 :
                photo_url = checkin["media"]["items"][0]["photo"]["photo_img_lg"]
                download = requests.get(photo_url)
                with open("untappd.tmp", 'wb') as temp_file:
                        temp_file.write(download.content)
                media = mastodon.media_post("untappd.tmp", description="A photo of some beer.")
                mastodon.status_post(status = message, media_ids=media, idempotency_key = str(untappd_id))
        else:   
                #       Post to Mastodon. Use idempotency just in case something went wrong
                mastodon.status_post(status = message, idempotency_key = str(untappd_id))
else :
        print("No new checkin")
</code></pre>

<p>You can treat this code as being MIT licenced if that makes you happy.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=50507&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/05/untappd-to-mastodon-updated/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[There should only ever be one way to express yourself]]></title>
		<link>https://shkspr.mobi/blog/2024/02/there-should-only-ever-be-one-way-to-express-yourself/</link>
					<comments>https://shkspr.mobi/blog/2024/02/there-should-only-ever-be-one-way-to-express-yourself/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sun, 11 Feb 2024 12:34:39 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[Computer Science]]></category>
		<category><![CDATA[language]]></category>
		<category><![CDATA[programming]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=45224</guid>

					<description><![CDATA[I&#039;ve been thinking about programming languages and their design.  In her book about the divergence of the English and American languages, Lynne Murphy asks this question:  wouldn’t it be great if language were logical and maximally efficient? If sentences had only as many syllables as strictly needed? If each word had a single, unique meaning? If there were no homophones, so we’d not be able to mi…]]></description>
										<content:encoded><![CDATA[<p>I've been thinking about programming languages and their design.</p>

<p>In her book about the divergence of the English and American languages, Lynne Murphy asks this question:</p>

<blockquote><p>wouldn’t it be great if language were logical and maximally efficient? If sentences had only as many syllables as strictly needed? If each word had a single, unique meaning? If there were no homophones, so we’d not be able to mix up dear and deer or two and too?</p></blockquote>

<p>That got me thinking about the creativity which can be expressed in code - and whether its a good thing.</p>

<p>Let's take an incredibly simple and common operation - incrementing an integer variable by one.  How would you do that? You've probably see these variations:</p>

<pre><code class="language-_">$i = $i + 1;
</code></pre>

<p>or</p>

<pre><code class="language-_">$i = $i++;
</code></pre>

<p>or</p>

<pre><code class="language-_">$i = 1 + $i;
</code></pre>

<p>or</p>

<pre><code class="language-_">$i = int( float_adder( float($i), 1.00 ) );
</code></pre>

<p>or</p>

<pre><code class="language-_">i1, i2 = i1^i2, (i1&amp;i2) &lt;&lt; 1 
</code></pre>

<p>I'm sure you can come up with a few more esoteric methods.</p>

<p>The Python programming language has a <a href="https://legacy.python.org/dev/peps/pep-0020/">list of aphorisms for good programming practice</a>. One of which is:</p>

<blockquote><p>There should be one-- and preferably only one --obvious way to do it.</p></blockquote>

<p>Is that right? As described in <a href="https://blog.startifact.com/posts/older/what-is-pythonic.html">What is Pythonic?</a>, the Python language itself has multiple ways to accomplish one thing.</p>

<p>But, is it a good idea?</p>

<p>Back to Lynne Murphy again:</p>

<blockquote><p>No, absolutely not. No way. Quit even thinking that. What are you, some kind of philistine? If Shakespeare hadn’t played with the number of syllables in his sentences, he would not have been able to communicate in iambic pentameter.</p></blockquote>

<p>Shakespeare wasn't writing Python though, was he?</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=45224&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/02/there-should-only-ever-be-one-way-to-express-yourself/feed/</wfw:commentRss>
			<slash:comments>7</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Compressing Text into Images]]></title>
		<link>https://shkspr.mobi/blog/2024/01/compressing-text-into-images/</link>
					<comments>https://shkspr.mobi/blog/2024/01/compressing-text-into-images/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sat, 13 Jan 2024 12:34:11 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[compression]]></category>
		<category><![CDATA[Computer Science]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=49184</guid>

					<description><![CDATA[(This is, I think, a silly idea. But sometimes the silliest things lead to unexpected results.)  The text of Shakespeare&#039;s Romeo and Juliet is about 146,000 characters long. Thanks to the English language, each character can be represented by a single byte.  So a plain Unicode text file of the play is about 142KB.  In Adventures With Compression, JamesG discusses a competition to compress text…]]></description>
										<content:encoded><![CDATA[<p>(This is, I think, a silly idea. But sometimes the silliest things lead to unexpected results.)</p>

<p>The text of Shakespeare's Romeo and Juliet is about 146,000 characters long. Thanks to the English language, each character can be represented by a single byte.  So a plain Unicode text file of the play is about 142KB.</p>

<p>In <a href="https://jamesg.blog/2023/12/29/compression-adventures/">Adventures With Compression</a>, JamesG discusses a competition to compress text and poses an interesting thought:</p>

<blockquote><p>Encoding the text as an image and compressing the image. I would need to use a lossless image compressor, and using RGB would increase the number of values associated with each word. Perhaps if I changed the image to greyscale? Or perhaps that is not worth exploring.
</p></blockquote>

<p>Image compression algorithms are, generally, pretty good at finding patterns in images and squashing them down. So if we convert text to an image, will image compression help?</p>

<p>The English language and its punctuation are not very complicated, so the play only contains 77 unique symbols. The ASCII value of each character spans from 0 - 127. So let's create a greyscale image which each pixel has the same greyness as the ASCII value of the character.</p>

<p>Here's what it looks like when losslessly compressed to a PNG:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/01/ascii_grey.png" alt="Random grey noise." width="512" height="277" class="aligncenter size-full wp-image-49360">

<p>That's down to 55KB! About 40% of the size of the original file. It is slightly <em>smaller</em> than ZIP, and about 9 bytes larger than Brotli compression.</p>

<p>The file can be read with the following Python:</p>

<pre><code class="language-python">from PIL import Image
image  = Image.open("ascii_grey.png")
pixels = list(image.getdata())
ascii  = "".join([chr(pixel) for pixel in pixels])
with open("rj.txt", "w") as file:
    file.write(ascii)
</code></pre>

<p>But, even with the latest image compression algorithms, it is unlikely to compress much further; the image looks like random noise.  Yes, you and I know there is data in there. And a statistician looking for entropy would probably determine that the file contains readable data. But image compressors work in a different realm. They look for solid blocks, or predictable gradients, or other statistical features.</p>

<p>But there you go! A lossless image is a pretty efficient way to compress ASCII text.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=49184&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/01/compressing-text-into-images/feed/</wfw:commentRss>
			<slash:comments>12</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Converting MoneyDashboard's export file to a CSV - for Firefly III and others]]></title>
		<link>https://shkspr.mobi/blog/2023/10/converting-moneydashboards-export-file-to-a-csv-for-firefly-iii-and-others/</link>
					<comments>https://shkspr.mobi/blog/2023/10/converting-moneydashboards-export-file-to-a-csv-for-firefly-iii-and-others/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Tue, 24 Oct 2023 11:34:04 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[money]]></category>
		<category><![CDATA[openbanking]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=48478</guid>

					<description><![CDATA[As I mentioned last week, MoneyDashboard is shutting down. They are good enough to provide a JSON export of all your previous transactions.  It is full of entries like this:  {     &#34;Account&#34;: &#34;My Mastercard&#34;,     &#34;Date&#34;: &#34;2020-02-24T00:00:00Z&#34;,     &#34;CurrentDescription&#34;: null,     &#34;OriginalDescription&#34;: &#34;SUMUP *Pizza palace, London, W1&#34;,     &#34;Amount&#34;: -12.34,     &#34;L1Tag&#34;: &#34;Eating Out&#34;,    …]]></description>
										<content:encoded><![CDATA[<p>As I mentioned last week, <a href="https://shkspr.mobi/blog/2023/10/why-is-there-no-openbanking-api-for-personal-use/">MoneyDashboard is shutting down</a>. They are good enough to provide a JSON export of all your previous transactions.</p>

<p>It is full of entries like this:</p>

<pre><code class="language-json">{
    "Account": "My Mastercard",
    "Date": "2020-02-24T00:00:00Z",
    "CurrentDescription": null,
    "OriginalDescription": "SUMUP *Pizza palace, London, W1",
    "Amount": -12.34,
    "L1Tag": "Eating Out",
    "L2Tag": "Pizza",
    "L3Tag": ""
},
{
    "Account": "American Express",
    "Date": "2019-01-11T00:00:00Z",
    "CurrentDescription": null,
    "OriginalDescription": "Work Canteen,Norwich",
    "Amount": -5,
    "L1Tag": "Lunch",
    "L2Tag": "",
    "L3Tag": ""
}
</code></pre>

<p>Let's write a quick bit of Python to turn that into CSV.  This will turn the above into two separate files.</p>

<p><code>My Mastercard.csv</code>:</p>

<pre><code class="language-csv">Data,       Description,                       Destination,  Amount
2020-02-24, "SUMUP *Pizza palace, London, W1", Pizza palace, -12.34
</code></pre>

<p>And <code>American Express.csv</code>:</p>

<pre><code class="language-csv">Data,       Description,            Destination,  Amount
2019-01-11, "Work Canteen,Norwich", Work Canteen, -5
</code></pre>

<p>I didn't make much use of MoneyDashboard's tagging, so I've ignored them.  The destination (which is the name of "opposing bank" in Firefly III speak) ignores the payment processor like <code>SUMUP</code> or <code>PAYPAL</code> and anything after the first comma.</p>

<p>It also sorts the CSV into date order. It's not very efficient, but you'll only run it once.</p>

<pre><code class="language-python">import json
import csv
import os
from datetime import datetime

#   Read the file
json_file = open( "md.json" )
data = json.load( json_file )
transactions = data["Transactions"]

#   Loop through the transactions
for transaction in transactions:
    #   Get the filename
    filename = transaction["Account"]

    #   Format the date
    date = datetime.strptime(transaction["Date"], "%Y-%m-%dT%H:%M:%SZ")
    formatted_date = date.strftime("%Y-%m-%d")

    #   The description
    description = transaction["OriginalDescription"]

    #   The destination is everything after the first " *" (if it exists) and before the first comma
    #   For example: "SUM *Pizza Place, London" becomes "Pizza Place"
    destination = description.split(',')[0]
    if " *" in destination:
        destination = destination.split(" *")[1]

    #   Monetary amount
    amount = transaction["Amount"]

    #   Create the file if it doesn't exist
    if not os.path.exists(f'{filename}.csv'):
        with open(f'{filename}.csv', mode='w', newline='') as file:
            writer = csv.writer(file)
            writer.writerow(["Date", "Description", "Destination", "Amount"])

    #   Read the file and split the header from the existing data
    with open(f'{filename}.csv', mode='r', newline='') as file:
        reader = csv.reader(file)
        existing_data = list(reader)
        header = existing_data[0]
        data_rows = existing_data[1:]

    data_rows.append( [formatted_date, description, destination, amount] )

    #   Sort the data by the first column (string)
    sorted_data = sorted(data_rows, key=lambda x: x[0])

    #   Save the file back again
    with open(f'{filename}.csv', mode='w', newline='') as file:
        writer = csv.writer(file)
        writer.writerows([header] + sorted_data)
</code></pre>

<p>Run that against your MoneyDashboard export and you can then import the CSV files into MoneyDashboard, GNUCash, or anything else.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=48478&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2023/10/converting-moneydashboards-export-file-to-a-csv-for-firefly-iii-and-others/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[How far did my post go on the Fediverse?]]></title>
		<link>https://shkspr.mobi/blog/2023/09/how-far-did-my-post-go-on-the-fediverse/</link>
					<comments>https://shkspr.mobi/blog/2023/09/how-far-did-my-post-go-on-the-fediverse/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Tue, 26 Sep 2023 11:34:28 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[mastodon]]></category>
		<category><![CDATA[MastodonAPI]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=46831</guid>

					<description><![CDATA[I wrote a moderately popular post on Mastodon. Lots of people shared it. Is it possible to find out how many different ActivityPub servers it went to?  Yes!  As we all know, the Fediverse is one big chain mail.  I don&#039;t mean that in a derogatory way.  When I write a post, it appears on my server (called an &#34;instance&#34; in Mastodon-speak).  Everyone on my instance can see my post.  My instance looks …]]></description>
										<content:encoded><![CDATA[<p>I wrote a moderately popular post on Mastodon. Lots of people shared it. Is it possible to find out how many different ActivityPub servers it went to?</p>

<p>Yes!</p>

<p>As we all know, <a href="https://m.fa.gl/@joshbal4/111048499514776098">the Fediverse is one big chain mail</a>.  I don't mean that in a derogatory way.</p>

<p>When I write a post, it appears on my server (called an "instance" in Mastodon-speak).</p>

<p>Everyone on my instance can see my post.</p>

<p>My instance looks at all my followers - some of whom are on completely different instances - and sends my post to their instances.</p>

<p>As an example:</p>

<ul>
<li>I am on <code>mastodon.social</code></li>
<li>John is on <code>eggman_social.com</code></li>
<li>Paul is on <code>penny_lane.co.uk</code></li>
<li>Both John and Paul follow me. So my post gets syndicated to their servers.</li>
</ul>

<p>With me so far?</p>

<p>What happens when someone shares (reposts) my status?</p>

<ul>
<li>John is on <code>eggman_social.com</code></li>
<li>Ringo is on <code>liverpool.drums</code></li>
<li>Ringo follows John</li>
<li>John reposts my status.</li>
<li><code>eggman_social.com</code> syndicates my post to <code>liverpool.drums</code></li>
</ul>

<p>And so my post goes around the Fediverse!  But can I see where it has gone?  Well... sort of! Let's look at how.</p>

<h2 id="a-note-on-privacy"><a href="https://shkspr.mobi/blog/2023/09/how-far-did-my-post-go-on-the-fediverse/#a-note-on-privacy">A note on privacy</a></h2>

<p>People on Mastodon and the Fediverse tend to be privacy conscious. So there are limits - both in the API and the culture - as to what is acceptable.</p>

<p>Some people don't share their "social graph". That is, it is impossible to see who follows them or who they follow.</p>

<p>Users can choose to opt-in or -out of publicly sharing their social graph. They remain in control of their privacy.</p>

<p>In the example above, if Ringo were to reshare John's reshare of my status - John doesn't know about it. Only the original poster (me) gets notified. If John doesn't share his social graph, it <em>might</em> be possible to work out where Ringo saw the status - but that's rather unlikely.</p>

<p>Mastodon has an API rate limit which only allows 80 results per request and 1 request per second. That makes it long and tedious to crawl thousands of results.</p>

<p>Similarly, some instances do not share their social data or expose anything of significance. Some servers may no longer exist, or might have changed names. It's impossible to get a comprehensive view of the entire Fediverse network.</p>

<p>And that's OK! People should be able to set limits on what others can do with their data.  The code you're about to see doesn't attempt to breach anyone's privacy. All it does is show me which servers picked up my post. This is information which is already shown to me - but this makes it slightly easier to see.</p>

<h2 id="the-result"><a href="https://shkspr.mobi/blog/2023/09/how-far-did-my-post-go-on-the-fediverse/#the-result">The Result</a></h2>

<p>I looked at <a href="https://mastodon.social/@Edent/111040801202691232">this post of mine</a> which was reposted over 100 times.</p>

<p>It eventually found its way to… <strong>2,547</strong> instances!</p>

<p>Ranging from <code>0ab.uk</code> to <code>թութ.հայ</code> via <code>godforsaken.website</code> and many more!</p>

<p>And that's one of the things which makes me hopeful this rebellion will succeed. There are a thousand points of light out there - each a shining beacon to doing things differently. And, the more the social media giants tighten their grip, the more these systems will slip through their fingers.</p>

<h2 id="the-code"><a href="https://shkspr.mobi/blog/2023/09/how-far-did-my-post-go-on-the-fediverse/#the-code">The Code</a></h2>

<p>This is not very efficient code - nor well written. It was designed to scratch an itch.  It uses <a href="https://mastodonpy.readthedocs.io">Mastodon.py</a> to interact with the API.</p>

<p>It gets the instance names of all my followers. Then the instance names of everyone who reposted one of <em>my</em> posts.</p>

<p>But it cannot get the instance names of <em>everyone</em> who follows the users who reposted me - because:
<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/09/Screenshot-2023-09-17-at-20-22-10-Mastodon.png" alt="Followers from other servers are not displayed. Browse more on the original profile." width="418" height="82" class="aligncenter size-full wp-image-47143">
The only way to get a list of followers from a user on a different instance is to apply for an API key for that instance. Which seems a bit impractical.</p>

<p>But I can get the instance name of the followers of accounts on my instance who reposted me. Clear?</p>

<p>I can also get a list of everyone who favourited my post. If they aren't on my instance, or one of my reposter's follower's instances, they're probably from a reposter who isn't on my instance.</p>

<p>My head hurts.</p>

<p>Got it? Here we go!</p>

<pre><code class="language-python">import config
from mastodon import Mastodon
from rich.pretty import pprint

#  Set up access
mastodon = Mastodon( api_base_url=config.instance, access_token=config.access_token, ratelimit_method='pace' )

#   Status to check for
status_id = 111040801202691232
print("Looking up status: " + str(status_id))

#   Get my data
me = mastodon.me()
my_id = me["id"]
print("You have User ID: " + str(my_id))

#   Empty sets
instances_all        = set()
instances_followers  = set()
instances_reposters  = set()
instances_reposters_followers  = set()
instances_favourites = set()

#   My Followers
followers = mastodon.account_followers( my_id )
print( "Getting all followers" )
followers_all = mastodon.fetch_remaining( followers )
print("Total followers = " + str( len(followers_all) ) )

#   Get the server names of all my followers
for follower in followers_all:
    if ( "@" in follower["acct"]) :
        f = follower["acct"].split("@")[1]
        instances_all.add( f )
        if ( f not in instances_followers):
            print( "Follower: " + f )
            instances_followers.add( f )
    else :
        instances_all.add( "mastodon.social" )
        instances_followers.add( "mastodon.social" )
total_followers  = len(instances_followers)
print( "Total Unique Followers Instances = " + str(total_followers)  )

#   Reposters
#   Find the accounts which reposted my status
reposters     = mastodon.status_reblogged_by( status_id )
reposters_all = mastodon.fetch_remaining(reposters)

#   Get all the instance names of my reposters
for reposter in reposters_all:
    if ( "@" in reposter["acct"]) :
        r = reposter["acct"].split("@")[1]
        instances_all.add( r )
        if ( r not in instances_followers ) :
            print( "Reposter: " + r )
            instances_reposters.add( r )
total_reposters  = len(instances_reposters)
print( "Total Unique Reposters Instances = " + str(total_reposters)  )

# Followers of reposters     
# This can take a *long* time!   
for reposter in reposters_all:   
    if ( "@" not in reposter["acct"]) :  
        reposter_id = reposter["id"]
        print( "Getting followers of reposter " + reposter["acct"] + " with ID " + str(reposter_id) )
        reposter_followers = mastodon.account_followers( reposter_id )   
        reposter_followers_all = mastodon.fetch_remaining( reposter_followers )  
        for reposter_follower in reposter_followers_all:    
            if ( "@" in reposter_follower["acct"]) : 
                f = reposter_follower["acct"].split("@")[1]
                instances_all.add( f )
                if (f not in instances_reposters_followers) :
                    print( "   Adding " + f + " from " + reposter["acct"] )
                    instances_reposters_followers.add( f )   
total_instances_reposters_followers  = len(instances_reposters_followers)
print( "Total Unique Reposters' Followers Instances = " + str(total_instances_reposters_followers)  )

#   Favourites
#   Find the accounts which favourited my status
favourites     = mastodon.status_favourited_by( status_id )
favourites_all = mastodon.fetch_remaining(favourites)

#   Get all the instance names of my favourites
for favourite in favourites_all:
    if ( "@" in favourite["acct"]) :
        f = favourite["acct"].split("@")[1]
        instances_all.add( f )
        if ( f not in instances_favourites ) :
            print( "Favourite: " + f )
            instances_favourites.add( r )
total_favourites = len(instances_favourites)

print( "Total Unique Favourites Instances  = " + str(total_favourites) )
print( "Total Unique Reposters Instances = " + str(total_reposters)  )
print( "Total Unique Followers Instances = " + str(total_followers)  )
print( "Total Unique Reposters' Followers Instances = " + str( len(instances_reposters_followers) ) )
print( "Total Unique Instances = " + str( len(instances_all) ) )
</code></pre>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=46831&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2023/09/how-far-did-my-post-go-on-the-fediverse/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Using Selenium & Chrome to automatically download Blob files]]></title>
		<link>https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/</link>
					<comments>https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Fri, 15 Sep 2023 11:34:46 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[python]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=46799</guid>

					<description><![CDATA[The Selenium WebDriver is a brilliant way to programmatically interact with websites. You can write little Python scripts which can click around inside browser windows and do &#34;stuff&#34;.  I use it to download a file generated by a Javascript Blob and automatically save it to disk. Here&#039;s how.  Set up the WebDriver  After you&#039;ve installed Selenium and the Chrome WebDriver, this is the standard…]]></description>
										<content:encoded><![CDATA[<p>The <a href="https://www.selenium.dev/">Selenium WebDriver</a> is a brilliant way to programmatically interact with websites. You can write little Python scripts which can click around inside browser windows and do "stuff".</p>

<p>I use it to download a file generated by a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Javascript Blob</a> and automatically save it to disk. Here's how.</p>

<h2 id="set-up-the-webdriver"><a href="https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/#set-up-the-webdriver">Set up the WebDriver</a></h2>

<p>After you've installed Selenium and the Chrome WebDriver, this is the standard boilerplate to use it in Python:</p>

<pre><code class="language-python">from selenium import webdriver 
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
</code></pre>

<h2 id="set-up-chrome"><a href="https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/#set-up-chrome">Set Up Chrome</a></h2>

<p>You can pass whatever options and arguments you need - I use it in headless mode which means it doesn't display a window.</p>

<pre><code class="language-python">chrome_options = Options()
chrome_options.add_argument('--headless=new')
chrome_options.add_argument('--window-size=1920,1080')
</code></pre>

<h2 id="set-where-to-save-the-files"><a href="https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/#set-where-to-save-the-files">Set where to save the files</a></h2>

<p>These options force the blob to download automatically to a specific location. 
<strong>Note</strong> There is no way to set the default file name.</p>

<pre><code class="language-python">chrome_options.add_experimental_option("prefs", {
        "download.default_directory"  : "/tmp/",
        "download.prompt_for_download": False,
        "download.directory_upgrade"  : True,
})
</code></pre>

<h2 id="download-the-file"><a href="https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/#download-the-file">Download the file</a></h2>

<p>This opens the browser, finds the link, then clicks on it. Your <code>XPATH</code> will be different to mine.</p>

<pre><code class="language-python">driver = webdriver.Chrome(options=chrome_options)
driver.get("https://example.com")

download_button = driver.find_element(By.XPATH, "//button[@data-integration-name='button-download-data-csv']")
download_button.click()
</code></pre>

<h2 id="rename-from-the-default-name"><a href="https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/#rename-from-the-default-name">Rename from the default name</a></h2>

<p>As mentioned, there's no way to set a default file name. But if you know what the file is going to be called, you can rename after it has been downloaded.</p>

<pre><code class="language-python">time.sleep(2) # Wait until the file has been downloaded. Increase if it is a big file
os.rename("/tmp/example.csv", "/home/me/newfile.csv")

#       Stop the driver
driver.quit()
</code></pre>

<p>And there you go. Stick that in a script and you can automatically download and rename Blob URls.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=46799&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2023/09/using-selenium-chrome-to-automatically-download-blob-files/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Importing IntenseDebate Comment XML into Commentics]]></title>
		<link>https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/</link>
					<comments>https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Fri, 04 Aug 2023 11:34:29 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[tutorial]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=46329</guid>

					<description><![CDATA[This is ridiculously niche. If this is of help to anyone other than to me... please shout!  The IntenseDebate comment system is slowly dying. It hasn&#039;t received any updates from Automattic for years. Recently it stopped being able to let users submit new comments.  So I&#039;ve switched to Commentics which is a self-hosted PHP / MySQL comment system. It&#039;s lightweight, pretty good at respecting users&#039;…]]></description>
										<content:encoded><![CDATA[<p>This is <em>ridiculously</em> niche. If this is of help to anyone other than to me... please shout!</p>

<p>The <a href="https://www.intensedebate.com/">IntenseDebate</a> comment system is slowly dying. It hasn't received any updates from Automattic for years. Recently it stopped being able to let users submit new comments.</p>

<p>So I've switched to <a href="https://commentics.com/">Commentics</a> which is a self-hosted PHP / MySQL comment system. It's lightweight, pretty good at respecting users' privacy, and very customisable.  But it doesn't let you easily import comments.  Here's how I fixed that.</p>

<h2 id="export-from-intensedebate"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#export-from-intensedebate">Export From IntenseDebate</a></h2>

<p>Go to your site's dashboard and click export. They'll email you a link when the process has finished.  It's an XML file which looks something like this:</p>

<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;output&gt;
    &lt;blogpost&gt;
        &lt;url&gt;https%3A%2F%2Fopenbenches.org%2Fbench%2F123&lt;/url&gt;
        &lt;title&gt;The Page Title&lt;/title&gt;
        &lt;guid&gt;https://openbenches.org/bench/123&lt;/guid&gt;
        &lt;comments&gt;
            &lt;comment id='123456798' parentid='0'&gt;
                &lt;isAnon&gt;0&lt;/isAnon&gt;
                &lt;name&gt;&lt;![CDATA[Terence Eden]]&gt;&lt;/name&gt;
                &lt;email&gt;terence.eden@example.com&lt;/email&gt;
                &lt;url&gt;https://example.com&lt;/url&gt;
                &lt;ip&gt;198.51.100.123&lt;/ip&gt;
                &lt;text&gt;&lt;![CDATA[You can read more about the song at Wikipedia &lt;a href="https://en.wikipedia.org/wiki/Pokarekare_Ana" target="_blank"&gt;https://en.wikipedia.org/wiki/Pokarekare_Ana&lt;/a&gt;  ]]&gt;&lt;/text&gt;
                &lt;date&gt;2017-08-01 14:51:55&lt;/date&gt;
                &lt;gmt&gt;2017-08-01 14:51:55&lt;/gmt&gt;
                &lt;score&gt;1&lt;/score&gt;
            &lt;/comment&gt;
        &lt;/comments&gt;
    &lt;/blogpost&gt;
    ...
</code></pre>

<h2 id="understand-the-commentics-table-structure"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#understand-the-commentics-table-structure">Understand the Commentics Table Structure</a></h2>

<p>Once you've installed and configured Commentics, you will be able to replace the database with your old comments. To do that, you'll need to convert your exported XML file into three CSV files.</p>

<p>there are 3 tables you need to understand.</p>

<h3 id="users-table"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#users-table">Users Table</a></h3>

<p>This is the easiest one to understand.  The columns are:</p>

<p><code>id, avatar_id, avatar_pending_id, avatar_selected, avatar_login, name, email, moderate, token, to_all, to_admin, to_reply, to_approve, format, ip_address, date_modified, date_added</code></p>

<p>Hopefully they're self-explanatory. The <code>moderate</code> is always set to <code>default</code>. The <code>token</code> can be any random string.  A typical user will look like this:</p>

<p><code>2, 0, 0, , , Terence Eden, terence.eden@example.com, default, abc123, 1, 1, 1, 1, html, 127.0.0.1, 2017-08-01 14:51:55, 2017-08-01 14:51:55</code></p>

<p><strong>Note</strong> The user's URL is <em>not</em> part of this table! That confused me at first. It is part of the <code>comment</code> table.</p>

<h3 id="pages-table"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#pages-table">Pages Table</a></h3>

<p>Every comment is associated with a page. Therefore every page needs a table.</p>

<p><code>id, site_id, identifier, reference, url, moderate, is_form_enabled, date_modified, date_added</code></p>

<p>Again, pretty straightforward. A typical page looks like:</p>

<p><code>123, 1, openbenches.org/bench/123, OpenBenches - Bench 123, https://openbenches.org/bench/123, default, 1, 2023-07-16 21:29:52, 2023-07-16 21:29:52</code></p>

<p>The <code>id</code> is a unique number. I've set it to be the same as my page's actual ID - but it doesn't need to be.</p>

<h3 id="comments-table"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#comments-table">Comments Table</a></h3>

<p>This is a slightly cumbersome table:</p>

<p><code>id, user_id, page_id, website, town, state_id, country_id, rating, reply_to, headline, original_comment, comment, reply, ip_address, is_approved, notes, is_admin, is_sent, sent_to, likes, dislikes, reports, is_sticky, is_locked, is_verified, number_edits, session_id, date_modified, date_added</code></p>

<p>You can ignore most of them - unless you <em>really</em> want to record someone's home town - and a typical user looks like this:</p>

<p><code>4, 17, 123, , , , , , , , Wow! That's a great bench. And I imagine the view must be special too.   , Wow! That's a great bench. And I imagine the view must be special too., , 127.0.0.1, 1, Moderating all comments., 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, abc123, 2017-08-10 17:56:50, 2017-08-10 17:56:50</code></p>

<p>The <code>id</code> is unique per comment. The <code>user_id</code> is matched to the <code>id</code> on the <code>users</code> table. And the <code>page_id</code> is the <code>id</code> on the <code>pages</code> table. <code>notes</code> appears to be hardcoded to <code>Moderating all comments.</code></p>

<h2 id="horrible-evil-no-good-python"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#horrible-evil-no-good-python">Horrible evil no-good Python</a></h2>

<p>Here's a Python script I made after a few beers. It ingests the XML file and spits out 3 CSV files.</p>

<pre><code class="language-python">import csv
import random
import string

import xml.etree.ElementTree as ET
tree = ET.parse('IntenseDebate.xml')
root = tree.getroot()

comment_header = ["id","user_id","page_id","website","town","state_id","country_id","rating","reply_to","headline","original_comment","comment","reply","ip_address","is_approved","notes","is_admin","is_sent","sent_to","likes","dislikes","reports","is_sticky","is_locked","is_verified","number_edits","session_id","date_modified","date_added"]

i = 1

with open('comments_to_import.csv', 'w') as file:
    writer = csv.writer(file)
    # writer.writerow(comment_header)   
    for child in root:
        for children in child:
            if (children.tag == "guid") :
                guid = children.text
                page_id = int(''.join(filter(str.isdigit, guid)))
            if (children.tag == "comments") :
                for comments in children :
                    commentID = comments.attrib["id"]
                    for commentData in comments :
                        if (commentData.tag == "name") :
                            username = commentData.text
                        if (commentData.tag == "text") :
                            commentHTML = commentData.text
                        if (commentData.tag == "url") :
                            userurl = commentData.text
                        if (commentData.tag == "date") :
                            commentDate = commentData.text
                            i = i+1
                            digits  = random.choices(string.digits, k=10)
                            letters = random.choices(string.ascii_lowercase, k=10)
                            sample  = random.sample(digits + letters, 20)
                            session_id  = "".join(sample)
                            row = [i,i,page_id,userurl,"","","","","","",commentHTML,commentHTML,"","127.0.0.1","1","Moderating all comments.","0","1","0","0","0","0","0","0","0","0",session_id,commentDate,commentDate]
                            writer.writerow(row)

pages_header = ["id","site_id","identifier","reference","url","moderate","is_form_enabled","date_modified","date_added"]
with open('pages_to_import.csv', 'w') as file:
    writer = csv.writer(file)
    # writer.writerow(pages_header)
    for i in range(30000):
        row = [i,"1","openbenches.org/bench/" + str(i),"OpenBenches - Bench " + str(i),"https://openbenches.org/bench/" + str(i),"default","1","2023-07-16 21:29:52","2023-07-16 21:29:52"]
        writer.writerow(row)

users_header = ["id","avatar_id","avatar_pending_id","avatar_selected","avatar_login","name","email","moderate","token","to_all","to_admin","to_reply","to_approve","format","ip_address","date_modified","date_added"]
i = 1
with open('users_to_import.csv', 'w') as file:
    writer = csv.writer(file)
    # writer.writerow(users_header)
    for child in root:
        for children in child:
            if (children.tag == "guid") :
                guid = children.text
                page_id = int(''.join(filter(str.isdigit, guid)))
            if (children.tag == "comments") :
                for comments in children :
                    commentID = comments.attrib["id"]
                    for commentData in comments :
                        if (commentData.tag == "name") :
                            username = commentData.text
                        if (commentData.tag == "url") :
                            userurl = commentData.text
                        if (commentData.tag == "date") :
                            commentDate = commentData.text
                            i = i+1
                            digits  = random.choices(string.digits, k=10)
                            letters = random.choices(string.ascii_lowercase, k=10)
                            sample  = random.sample(digits + letters, 20)
                            session_id  = "".join(sample)
                            row = [i,"0","0","","",username,"","default",session_id,"1","1","1","1","html","127.0.0.1",commentDate,commentDate]
                            writer.writerow(row)
</code></pre>

<p><strong>Note</strong> my pages all follow a numeric sequence <code>/1</code>, <code>/2</code> etc, hence the loop to quickly regenerate them. Your pages may be different.</p>

<h2 id="importing"><a href="https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/#importing">Importing</a></h2>

<p>You will need to use PHPmyAdmin or a similar database manager. <code>TRUNCATE</code> the tables for <code>pages</code>, <code>users</code>, and <code>comments</code>. Then import the CSV files into each one.</p>

<p>If everything works, you will have all your old comments imported into your new system.</p>

<p>Enjoy!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=46329&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2023/08/importing-intensedebate-comment-xml-into-commentics/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Shakespeare Serif - an experimental font based on the First Folio]]></title>
		<link>https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/</link>
					<comments>https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sat, 29 Jul 2023 11:34:57 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[font]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[shakespeare]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=46355</guid>

					<description><![CDATA[Disclaimer! Work In Progress! See source code.  I recently read this wonderful blog post about using 17th Century Dutch fonts on the web. And, because I&#039;m an idiot, I decided to try and build something similar using Shakespeare&#039;s first folio as a template.  Now, before setting off on a journey, it is worth seeing if anyone else has tried this before. I found David Pustansky&#039;s First Folio Font.…]]></description>
										<content:encoded><![CDATA[<p><mark>Disclaimer! Work In Progress! <a href="https://github.com/edent/Shakespeare-Serif-Font">See source code</a>.</mark></p>

<p>I recently read this <a href="https://www.linyangchen.com/Typography-Fell-Types-font">wonderful blog post about using 17th Century Dutch fonts on the web</a>. And, because I'm an idiot, I decided to try and build something similar using Shakespeare's first folio as a template.</p>

<p>Now, before setting off on a journey, it is worth seeing if anyone else has tried this before. I found <a href="https://www.dafont.com/shakespeare-first-folio-font.font">David Pustansky's First Folio Font</a>. There's not much info about it, other than it's based on the 1623 folio. It's a nice font, but missing brackets and a few other pieces of punctuation. Also, no ligatures. And the <a href="https://en.wikipedia.org/wiki/Long_s">long s</a> is in the wrong place.</p>

<p>So, let's try to build a font!</p>

<p>You can read how it works, or <a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#demo">skip straight to the demo</a>.</p>

<h2 id="get-some-scans"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#get-some-scans">Get some scans</a></h2>

<p>There are <a href="https://shkspr.mobi/blog/2023/02/shakespeares-missing-smile/">various scans of the First Folio</a>. I picked <a href="https://iiif.bodleian.ox.ac.uk/iiif/viewer/?iiif-content=https://iiif.bodleian.ox.ac.uk/iiif/manifest/390fd0e8-9eae-475d-9564-ed916ab9035c.json#?c=0&amp;m=0&amp;s=0&amp;cv=0&amp;r=0&amp;xywh=-5359%2C-401%2C16129%2C8017">The Bodlian's scan</a> as it seemed the highest resolution.</p>

<p>I plucked a couple of pages at random to see what I could find.  Of course, a modern font can't replicate the vagaries of hot metal printing. As you can see here, each letter "y" is substantially different.
<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/text-sample.jpg" alt="A sample of Shakespearean text from the First Folio. All the letters are subtly different." width="962" height="478" class="aligncenter size-full wp-image-46356"></p>

<p>Within the plays, there are some italic characters - which could be used to make a variant font. You can also see just how poor quality some of the letters are.
<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/text.jpg" alt="Closely typed Shakespearean text. The letters are indistinct, with bleed-through from the other side of the paper." width="1154" height="574" class="aligncenter size-full wp-image-46367"></p>

<p>There are also plenty of ligatures to choose from:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/ligatures.jpg" alt="Text showing ligatures." width="664" height="374" class="aligncenter size-full wp-image-46368">

<p>Ready? Let's go!</p>

<h2 id="extract-the-characters"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#extract-the-characters">Extract the characters</a></h2>

<p>This Python code reads in an image file. It then extracts every distinct letter, number, and punctuation mark.  It then detects which character it is and saves each glyph to disk with a filename like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/Letters-detected.jpg" alt="Screenshot of a file listing. The letter &quot;e&quot; is sometimes detected as a &quot;c&quot;." width="1024" height="360" class="aligncenter size-full wp-image-46369">

<p>As you can see, the text <em>detection</em> is good, but the letter <em>recognition</em> is poor.</p>

<pre><code class="language-python">import cv2
import pytesseract
from PIL import Image

def preprocess_image(image_path):
    # Load the image using OpenCV
    image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

    # Thresholding to convert to binary image
    _, binary_image = cv2.threshold(image, 128, 255, cv2.THRESH_BINARY_INV)

    # Find contours to isolate individual letters
    contours, _ = cv2.findContours(binary_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    return image, contours

def extract_and_save_letters(image, contours, output_directory):
    # Create output directory if it doesn't exist
    import os
    if not os.path.exists(output_directory):
        os.makedirs(output_directory)

    for i, contour in enumerate(contours):
        x, y, w, h = cv2.boundingRect(contour)

        # Crop and save each letter as a separate image
        letter_image = image[y:y + h, x:x + w]

        # (Don't) Perform OCR to extract the text (letter) from the contour
        letter_text = "_"
        #letter_text = pytesseract.image_to_string(letter_image, config='--psm 10')
        #letter_text = letter_text.strip()  # Remove leading/trailing whitespace

        # Create a filename with the detected letter
        letter_filename = f"letter_{letter_text}_{i}.png"

        letter_path = os.path.join(output_directory, letter_filename)
        cv2.imwrite(letter_path, letter_image)

if __name__ == "__main__":
    input_image_path = "letters.jpg"
    output_directory = "/tmp/letters/"

    # Preprocess the image
    image, contours = preprocess_image(input_image_path)

    # Perform OCR and save individual letters
    extract_and_save_letters(image, contours, output_directory)
</code></pre>

<p>Something to note - the <code>CHAIN_APPROX_SIMPLE</code> is looking for <em>contiguous</em> characters. So it loses the dots from <code>i</code>, <code>j</code>, <code>:</code>, and <code>;</code>. But it is <em>quick</em>.</p>

<h2 id="detecting-dots"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#detecting-dots">Detecting Dots</a></h2>

<p>In order to get glyphs which vertically separate, we need to <a href="https://docs.opencv.org/3.4/db/df6/tutorial_erosion_dilatation.html">vertically erode the image</a> so it looks like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/binary_erode.png" alt="Letters stretched vertically." width="776" height="330" class="aligncenter size-full wp-image-46428">

<pre><code class="language-python"># Erode the image vertically
kernel = np.array([[0, 0, 0, 0, 0],
                   [0, 0, 1, 0, 0],
                   [0, 0, 1, 0, 0],
                   [0, 0, 1, 0, 0],
                   [0, 0, 0, 0, 0]], dtype=np.uint8)

erode = cv2.erode(image, kernel,iterations = 6)
</code></pre>

<p>We use this eroded image for contiguous detection - but we do the actual cropping on the original image.</p>

<p>As you can see, it does make some character touch each other - which means you get occasional crops like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/character_8.png" alt="A g above an h." width="41" height="131" class="aligncenter size-full wp-image-46429">

<p>They can either be manually split, or ignored.</p>

<h2 id="put-each-letter-into-a-folder"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#put-each-letter-into-a-folder">Put each letter into a folder</a></h2>

<p>There's no automated way to do this. It's just a lot of tedious dragging and dropping. It's hard to tell the difference between o and O, or commas and apostrophes.</p>

<p>Ideally we want several of each glyph because we're about to...</p>

<h2 id="find-the-average-letterform"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#find-the-average-letterform">Find the average letterform</a></h2>

<p>Here's a selection of letter "e" images which were extracted.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/montage.png" alt="24 different &quot;e&quot; letters. Each one slightly misshapen." width="302" height="262" class="aligncenter size-full wp-image-46370">

<p>I didn't want to make some rather arbitrary decisions on which letters I like best. So I cheated.</p>

<p>I copied all the letter "e"s into a folder. I used Python to create the <em>average</em> letter based on the two-dozen or so that I'd extracted. This code takes all the images in a directory, and spits out a 1bpp average letter - like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/1bpp.png" alt="A black letter &quot;e&quot;." width="220" height="220" class="aligncenter size-full wp-image-46373">

<pre><code class="language-python">import os
import numpy as np
import argparse
import math
from PIL import Image

def get_arguments():
    ap = argparse.ArgumentParser()
    ap.add_argument('-l', '--letter', type=str,
                    help='The letter you want to average')
    arguments = vars(ap.parse_args())

    return arguments

def load_and_resize_images_from_directory(directory, target_size):
    image_files = [f for f in os.listdir(directory) if f.endswith(".png")]

    images = []
    for image_file in image_files:
        image_path = os.path.join(directory, image_file)
        print("Reading " + image_path)
        image = Image.open(image_path).convert("L")  # Convert to grayscale

        # Create a new white background image
        new_size = (target_size[0], target_size[1])
        new_image = Image.new("L", new_size, color=255)  # White background

        old_width, old_height = image.size

        # Center the image
        x1 = int(math.floor((target_size[0] - old_width)  / 2))
        y1 = int(math.floor((target_size[1] - old_height) / 2))

        # Paste the image at the center
        new_image.paste(image, (x1, y1, x1 + old_width, y1 + old_height))

        # Make it larger to see if that improves the curve detection  
        new_image = new_image.resize( (600,600), Image.LANCZOS)
        images.append(new_image)

    return images

def calculate_average_image(images):
    # Convert the list of images to numpy arrays
    images_array = [np.array(img) for img in images]

    # Calculate the average image along the first axis
    average_image = np.mean(images_array, axis=0)

    return average_image

def convert_to_1bpp(average_image, threshold=120):
    # Convert the average image to 1bpp by setting a threshold value
    binary_image = np.where(average_image &gt;= threshold, 255, 0).astype(np.uint8)

    return binary_image

def save_1bpp_image(binary_image, output_path):
    # Convert the numpy array to a binary image
    binary_image = Image.fromarray(binary_image, mode="L")

    # Save the 1bpp monochrome image to the specified output path
    binary_image.save(output_path)

if __name__ == "__main__":
    args = get_arguments()
    letter = args['letter']
    input_directory   = "../letters/" + letter + "/"
    output_png_path = "../letters/" + letter + ".png"
    target_size = (75, 75)  # Set the desired target size for resizing

    # Load, resize, and add border to all images from the directory
    images = load_and_resize_images_from_directory(input_directory, target_size)

    # Calculate the average image
    average_image = calculate_average_image(images)

    # Convert the average image to 1bpp
    binary_image = convert_to_1bpp(average_image)

    # Save the 1bpp monochrome image
    save_1bpp_image(binary_image, output_png_path)
</code></pre>

<h2 id="one-big-image"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#one-big-image">One Big Image</a></h2>

<p>The next step is to create a single image which holds all of the glyphs. Our good friend ImageMagick comes to the rescue here:</p>

<p><code>montage *.png -tile 12x8 -geometry +10+10 all_glyphs.png</code></p>

<p>That takes all of the average symbol .png files and places them on a single image. It looks like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/montage_glyphs.png" alt="Montage of all the letters and punctuation." width="1925" height="1449" class="aligncenter size-full wp-image-46435">

<h2 id="trace-those-glyphs"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#trace-those-glyphs">Trace Those Glyphs</a></h2>

<p>The <a href="https://github.com/jpakkane/glyphtracer">GlyphTracer App</a> will take the image and generates a <a href="https://fontforge.org/docs/techref/sfdformat.html">Spline Font Database</a>. It isn't the most intuitive app to use. But after a bit of clicking around you can work out how to assign each image to a glyph.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/GlyphTracer.png" alt="Screenshot showing the GlyphTracer program. Some of the letters are highlighted. There is an interface at the bottom to select a codepoint." width="1009" height="374" class="aligncenter size-full wp-image-46378">

<p>GlyphTracer uses <a href="https://potrace.sourceforge.net/">potrace</a> which turns those raggedy rasters into smoothly curved paths.</p>

<p>Once done, we're on to the next step.</p>

<h2 id="forge-those-fonts"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#forge-those-fonts">Forge Those Fonts!</a></h2>

<p>The venerable <a href="https://fontforge.org/">FontForge</a> will open the SFD and show us what the proto-font looks like:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/fontforge.png" alt="Collection of letters - each is vertically centred." width="813" height="417" class="aligncenter size-full wp-image-46379">

<p>As you can see, all the letters have been vertically centred. So double tap and edit their position - you can also adjust the curves if you like:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/FontForge-Glyph-editor.png" alt="The letter &quot;a&quot; shown as an outline - with lots of complicated controls to edit it." width="788" height="733" class="aligncenter size-full wp-image-46380">

<p>The final result looks something like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/07/Current-font.png" alt="Screenshot showing all the letters in more-or-less the right place" width="915" height="683" class="aligncenter size-full wp-image-46438">

<p>FontForge's "File" ➡ "Generate Font" will let you save the output as TTF, WOFF2, or anything else you want.</p>

<style>
@font-face { 
  font-family: "Shakespeare Serif";
  src: url(data:font/woff2;base64,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);
}
</style>

<h2 id="demo"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#demo">Demo!</a></h2>

<p>Here's what the font looks like when rendered on the web:<br><br></p>

<p><span style="font-family:'Shakespeare Serif';font-size:4em;line-height:1em;">Two houſeholds, both alike in dignity!</span>
<br>
<span style="font-family:'Shakespeare Serif';font-size:2em;line-height:1em;">Alas poor Yorik; I knew him Horatio.</span>
<br>
<span style="font-family:'Shakespeare Serif';font-size:2em;line-height:1em;">To be? Or not to be? That's the uestion.</span>
<br>
<span style="font-family:'Shakespeare Serif';font-size:2em;line-height:1em;">Bump sickly, vexing wizard! Be sly, fox, and charm the dragon's breath.</span></p>

<h2 id="todo"><a href="https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/#todo">TODO</a></h2>

<ul>
<li>Get more sample images from the 1st Folio.</li>
<li>Extract more letters, numbers, ligatures, and symbols.</li>
<li>Sort symbols into sub-directories.</li>
<li>Generate font with complete alphabet.</li>
<li>Tidy up curves.</li>
<li>Set correct height, ascenders, descenders, etc.</li>
<li>Make the ligatures automatic.</li>
<li>Other font stuff that I haven't even thought of yet!</li>
</ul>

<p>Want to help out?  <a href="https://github.com/edent/Shakespeare-Serif-Font">See the source code on GitHub</a>.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=46355&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2023/07/shakespeare-serif-a-new-font-based-on-the-first-folio/feed/</wfw:commentRss>
			<slash:comments>10</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Posting Untappd Checkins to Mastodon (and other services)]]></title>
		<link>https://shkspr.mobi/blog/2023/03/posting-untappd-checkins-to-mastodon-and-other-services/</link>
					<comments>https://shkspr.mobi/blog/2023/03/posting-untappd-checkins-to-mastodon-and-other-services/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 02 Mar 2023 12:34:56 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[beer]]></category>
		<category><![CDATA[cider]]></category>
		<category><![CDATA[mastodon]]></category>
		<category><![CDATA[MastodonAPI]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[untappd]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=45036</guid>

					<description><![CDATA[I&#039;m a big fan of Untappd. It&#039;s a social drinking app which lets you check in to a beer and rate it. Look, we all need hobbies, mine is drinking cider.  You can see a list of everything I&#039;ve drunk over the 13 last years. Nearly 900 different pints!  After checking in, the app automatically posts to Twitter. But who wants to prop up Alan&#039;s failing empire? Not me! So here&#039;s some quick code to…]]></description>
										<content:encoded><![CDATA[<p>I'm a big fan of <a href="https://untappd.com">Untappd</a>. It's a social drinking app which lets you check in to a beer and rate it. Look, we all need hobbies, mine is drinking cider.  You can <a href="https://untappd.com/user/edent">see a list of everything I've drunk over the 13 last years</a>. Nearly 900 different pints!</p>

<p>After checking in, the app automatically posts to Twitter. But who wants to prop up Alan's failing empire? Not me! So here's some quick code to liberate your data and post it elsewhere.</p>

<p>There are two ways - APIs and Screen Scraping.</p>

<h2 id="api"><a href="https://shkspr.mobi/blog/2023/03/posting-untappd-checkins-to-mastodon-and-other-services/#api">API</a></h2>

<p>First up, a big disclaimer. Untappd <em>had</em> an API - but aren't accepting new users:</p>

<blockquote><p>Thank you for your interest in Untappd’s API. At this time, we are no longer accepting new applications for API access as we work to improve our review and support processes. We do not have a planned date to begin accepting new applications, so please check back soon.</p></blockquote>

<p>If you already have an API key (and I do) you can call your own data. This code saves the ID of the most recent checkin to a file. Each time it runs, it checks in the ID in the file is the same as what's returned by the API. If they are different, the post is published and the new ID is saved.</p>

<p>This is rough and ready code:</p>

<pre><code class="language-python">#!/usr/bin/env python
from mastodon import Mastodon
import json
import requests
import config

#  Set up access
mastodon = Mastodon( api_base_url=config.instance, access_token=config.write_access_token )

#   Untappd API - grab the most recent checkin
untappd_api_url = 'https://api.untappd.com/v4/user/checkins/edent?limit=1&amp;client_id=' + config.untappd_client_id + '&amp;client_secret='+ config.untappd_client_secret

r = requests.get(untappd_api_url)

untappd_data = r.json()

#   Latest checkin object
checkin = untappd_data["response"]["checkins"]["items"][0]
untappd_id = checkin["checkin_id"]

#   Was this ID the last one we saw?
check_file = open("untappd_last", "r")
last_id = int( check_file.read() )
print("Found " + str(last_id) )
check_file.close()

if (last_id != untappd_id ) :
    print("Found new checkin")
    check_file = open("untappd_last", "w")
    check_file.write( str(untappd_id) )
    check_file.close()
    #   Start creating the message
    message = ""

    if "checkin_comment" in checkin :
        message += checkin["checkin_comment"]

    if "beer" in checkin :
        message += "\nDrinking: " + checkin["beer"]["beer_name"]

    if "brewery" in checkin :
        message += "\nBy: "       + checkin["brewery"]["brewery_name"]

    if "venue" in checkin :
        if "venue_name" in checkin["venue"] :
            message += "\nAt: "       + checkin["venue"]["venue_name"]
    #   Scores etc
    untappd_checkin_url = "https://untappd.com/user/edent/checkin/" + str(untappd_id)
    untappd_rating      = checkin["rating_score"]
    untappd_score       = "🍺" * int(untappd_rating)

    message += "\n" +  untappd_score + "\n" + untappd_checkin_url + "\n" + "#untappd"
    print( message )
    #   Post to Mastodon. Use idempotency just in case something went wrong
    mastodon.status_post(status = message, idempotency_key = str(untappd_id))
else :
    print("No new checkin")
</code></pre>

<p>This doesn't do media or badges, but it's good enough to start with.</p>

<h2 id="screen-scraping"><a href="https://shkspr.mobi/blog/2023/03/posting-untappd-checkins-to-mastodon-and-other-services/#screen-scraping">Screen Scraping</a></h2>

<p>The Untappd HTML is pretty uniform, so using something like <a href="https://beautiful-soup-4.readthedocs.io/en/latest/">Beautiful Soup</a> is possible.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2023/02/untappd_src.png" alt="HTML source code of the page." width="791" height="367" class="aligncenter size-full wp-image-45037">

<p>Here's some code to get you started</p>

<pre><code class="language-python">from bs4 import BeautifulSoup
import requests

#   Set the URL and headers
url = "https://untappd.com/user/edent"
headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0'}

#   Get the HTML
response = requests.get(url)
page_html = response.text

#   Parse the HTML into soup
soup = BeautifulSoup(page_html, 'html.parser')

#   Grab the data from the first checkin

untappd_id = int( soup.find("div", class_="item")["data-checkin-id"] )

comment = soup.find("p", class_="comment-text").text

#   Steal the beer from the icon's alt text
beer = soup.find("a", class_="label").find("img")["alt"]

#   Rating is a data element
rating = soup.find("div", class_="caps")["data-rating"]

#   Was this ID the last one we saw?
check_file = open("untappd_last", "r")
last_id = int( check_file.read() )
check_file.close()

if (last_id != untappd_id ) :
    check_file = open("untappd_last", "w")
    check_file.write( str(untappd_id) )
    check_file.close()
    message = .....
</code></pre>

<p>And from there you should have enough to start posting your checkins everywhere.  Stick that code in a crontab and have it run periodically.</p>

<p>Cheers!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=45036&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2023/03/posting-untappd-checkins-to-mastodon-and-other-services/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Getting Started with Mastodon's Conversations API]]></title>
		<link>https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/</link>
					<comments>https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 17 Nov 2022 12:34:10 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[api]]></category>
		<category><![CDATA[mastodon]]></category>
		<category><![CDATA[MastodonAPI]]></category>
		<category><![CDATA[NaBloPoMo]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[tutorial]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=44124</guid>

					<description><![CDATA[The social network service &#34;Mastodon&#34; allows people to publish posts. People can reply to those posts. Other people can reply to those replies - and so on.  What does that look like in the API?  Here&#039;s a quick guide to the concepts you need to know - and some code to help you visualise conversations.  When you scroll through the website, you normally see a list of replies.  It looks like this:    …]]></description>
										<content:encoded><![CDATA[<p>The social network service "Mastodon" allows people to publish posts. People can reply to those posts. Other people can reply to those replies - and so on.  What does that look like in the API?  Here's a quick guide to the concepts you need to know - and some code to help you visualise conversations.</p>

<p>When you scroll through the website, you normally see a list of replies.  It looks like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2022/11/conversation-fs8.png" alt="A list of posts. People are writing comments, but there's no link to whom they are replying." width="418" height="721" class="aligncenter size-full wp-image-44135">

<p>Because it acts as a one-dimensional list, there's no easy way to figure out which post someone is replying to.</p>

<p>The data structure underlying the conversation is quite different.  It actually looks like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2022/11/Conversation-ASCII-tree-fs8.png" alt="A threaded conversation. You can see the order in which people have replied to each other - and what posts they are referencing." width="315" height="379" class="aligncenter size-full wp-image-44136">

<h2 id="concepts"><a href="https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#concepts">Concepts</a></h2>

<p>In Mastodon's API, a post is called a <code>status</code>.</p>

<p>Every status on Mastodon has an ID.  This is <em>usually</em> a <a href="https://shkspr.mobi/blog/2022/11/building-an-on-this-day-service-for-mastodon/">Snowflake ID</a> which is represented as a number.</p>

<p>When someone replies to a status on Mastodon, they create a new status which has a field called <code>in_reply_to_id</code>. As its name suggests, has the ID of the status they are replying to.</p>

<p>Let's imagine this simple conversation:</p>

<ol>
<li>Ada: "How are you?"</li>
<li>Bob: "I'm fine. And you?"</li>
<li>Ada: "Quite well, thank you!"</li>
</ol>

<p>Message 2 is in reply to message 1.  Message 3 is in reply to message 2.</p>

<p>In Mastodon's jargon, message 1 is the <em>ancestor</em> of message 2. Similarly, message 3 is the <em>descendant</em> of message 2.</p>

<pre><code class="language-text">  → Descendants →
1--------2-------3
   ← Ancestors ←
</code></pre>

<h3 id="branches"><a href="https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#branches">Branches</a></h3>

<p>Now, let's imagine a more complicated conversation - one with branches!</p>

<pre><code class="language-text">1. Alice: What's your favourite pizza topping?
├── 2. Bette: Pineapple
│   ├── 4. Chuck: You make me sick!
│   └── 7. Dave: Yeah, I love pineapple too
└── 3. Chuck: Mushroom are the best
    ├── 5. Alice: Really?
    │   └── 6. Dave: Button mushrooms are best!
    └── 8. Elle: I like them too!
</code></pre>

<p>As you can see, people reply in threads.  In this example, <code>2</code> is a different "branch" of the conversations than <code>3</code>.</p>

<p>It looks a bit more complicated with hundreds of replies, but that's it! That's all you need to know!</p>

<h2 id="api"><a href="https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#api">API</a></h2>

<p>If you want to download a <em>single</em> status with an ID of <code>1234</code> the API call is <a href="https://docs.joinmastodon.org/methods/statuses/"><code>/api/v1/statuses/1234</code></a></p>

<p>If you want to download a conversation, it is a little bit more complicated. Mastdon's API calls a conversation a <a href="https://docs.joinmastodon.org/entities/context/"><code>context</code></a></p>

<p>Let's take the above simple example - Ada and Bob speaking. Ada's first status has an ID of <code>1</code>.  To get the conversation, the API call is <code>/api/v1/statuses/1/context</code></p>

<p>That returns two things:</p>

<ul>
<li>A list of <code>ancestors</code>. This is empty because <code>1</code> is the first status in this conversation.</li>
<li>A list of <code>descendants</code>. This contains statuses <code>2</code> and <code>3</code>.</li>
</ul>

<p>You will note, the <code>context</code> does <strong>not</strong> return the status <code>1</code> itself.</p>

<p>Let's suppose that, instead of asking for the context of status <code>1</code>, we instead asked for <code>2</code>. This would return:</p>

<ul>
<li>A list of <code>ancestors</code>. This contains status <code>1</code>.</li>
<li>A list of <code>descendants</code>. This contains status <code>3</code>.</li>
</ul>

<p>What about if we asked for <code>3</code>? This would return:</p>

<ul>
<li>A list of <code>ancestors</code>. This contains status <code>1</code> and <code>2</code></li>
<li>A list of <code>descendants</code>. This is empty because <code>3</code> is the last message in this conversation.</li>
</ul>

<h3 id="branches"><a href="https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#branches">Branches</a></h3>

<p>When it comes to complex threads - like the pizza example - things become a bit more difficult.  Let's see the example again:</p>

<pre><code class="language-text">1. Alice: What's your favourite pizza topping?
├── 2. Bette: Pineapple
│   ├── 4. Chuck: You make me sick!
│   └── 7. Dave: Yeah, I love pineapple too
└── 3. Chuck: Mushroom are the best
    ├── 5. Alice: Really?
    │   └── 6. Dave: Button mushrooms are best!
    └── 8. Elle: I like them too!
</code></pre>

<p>Suppose we ask for the <code>context</code> of the message with ID <code>5</code>.  This would return:</p>

<ul>
<li>A list of <code>ancestors</code>. This contains statuses <code>1</code> and <code>3</code></li>
<li>A list of <code>descendants</code>. This contains status <code>6</code>.</li>
</ul>

<p>That's it!?!? Where are the rest? They are part of a <em>different</em> conversation branch. Even status <code>8</code> isn't returned because it's a reply to <code>3</code>, not <code>5</code>.</p>

<p>In order to get the full conversation, we need to be sneaky!</p>

<p>The list of <code>ancestors</code> contains the first message in the conversation.  So we can grab that, and then call <code>context</code> again for its ID.</p>

<p>Let's dive into some Python code to see how it works.</p>

<h2 id="code"><a href="https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#code">Code</a></h2>

<p>This uses the <a href="https://mastodonpy.readthedocs.io/">Mastodon.py</a> library for calling the Mastodon API and the <a href="https://treelib.readthedocs.io/en/latest/">Python treelib</a> to create a conversation tree data structure.</p>

<p>This code connects to Mastodon and receives the status for a single ID.</p>

<pre><code class="language-python">from mastodon import Mastodon
from treelib import Node, Tree

mastodon = Mastodon( api_base_url="https://mastodon.example", access_token="Your personal access token from your instance" )

status_id =  109348943537057532 
status = mastodon.status(status_id)
</code></pre>

<p>Getting the conversation means calling the <code>context</code> API:</p>

<pre><code class="language-python">conversation = mastodon.status_context(status_id)
</code></pre>

<p><mark>⚠ Note:</mark> Calling the <code>context</code> on a large thread may take a long time. The longer the conversation, the longer you'll have to wait.</p>

<p>If there are ancestors, that means we are only on a single branch.  The 0th ancestor is the top of the conversation tree.  So let's get the <code>context</code> for that top status:</p>

<pre><code class="language-python">if len(conversation["ancestors"]) &gt; 0 :
   status = conversation["ancestors"][0]
   status_id = status["id"]
   conversation = mastodon.status_context(status_id)
</code></pre>

<p>Next, we need to create a data structure to hold the conversation.  We'll start by adding to it the first status in the conversation:</p>

<pre><code class="language-python">tree = Tree()

tree.create_node(status["uri"], status["id"])
</code></pre>

<p>Finally, we add any replies which are in the <code>descendants</code>. It is possible that some earlier statuses have been deleted. So we won't add any status which are replies to deleted statuses:</p>

<pre><code class="language-python">for status in conversation["descendants"] :
   try :
      tree.create_node(status["uri"], status["id"], parent=status["in_reply_to_id"])
   except :
      #  If a parent node is missing
      print("Problem adding node to the tree")
</code></pre>

<p>That's it! Let's show the tree:</p>

<pre><code class="language-python">tree.show()
</code></pre>

<p>Here's what it should look like:</p>

<pre><code class="language-text">2022-11-14 20:02 Edent: Today I was meant to be flying in to San Francisco to attend Twitter's Developer Conference - Chirp.Twitter had paid for my flights and hotel, because I was one of their developer insiders. I planned to spend the week meeting friends old and new.Instead, Alan the Hyperprat canceled the conference. So I'm staying in the UK.So I'm going to spend the week hacking on Mastdon's #API and building cool shit.  That'll show him!You can see what I'm working on at https://shkspr.mobi/blog/2022/11/building-an-on-this-day-service-for-mastodon/ https://mastodon.social/users/Edent/statuses/109343943300929632
├── 2022-11-14 20:10 Edent: Oh! And I was meant to be attending a Belle &amp; Sebastian gig tonight. I canceled those tickets for I could fly to SF.So far, I reckon Alan's acquisition of Twitter has cost me close to £190.Wonder if he's good for the money? https://mastodon.social/users/Edent/statuses/109343972435801664
│   ├── 2022-11-14 20:14 thehodge: @Edent reminds me of the time I was booked to speak at a conference in Munich and I excitedly booked a behind the scenes tour of the worlds largest miniature city!Then the company went under!Gutted. https://mastodon.social/users/thehodge/statuses/109343989481494630
│   ├── 2022-11-14 21:16 Janiqueka: @Edent the way my bill for him keeps increasing https://mastodon.online/users/Janiqueka/statuses/109344233355230523
│   ├── 2022-11-14 21:19 henry: @Edent I was due to be at B&amp;S tomorrow but it’s been postponed again.. not sure if that makes it better or worse for you! https://social.lc/users/henry/statuses/109344244402822729
│   │   └── 2022-11-15 04:53 Edent: @henry again!? Ah well!Hope you get to see them soon. https://mastodon.social/users/Edent/statuses/109346031194446940
│   ├── 2022-11-15 09:18 Amandafclark: @Edent send him an invoice :) https://mastodon.social/users/Amandafclark/statuses/109347071811426672
│   └── 2022-11-15 11:29 Edent: One of the #MastodonAPI projects I'm working on is a better way to view long &amp; complex threads.You may have seen me build something similar for the other site a while ago - demo at https://shkspr.mobi/blog/2021/09/augmented-reality-twitter-conversations/ - so I'm hoping I can do something similarly interesting.Main limitation is getting *all* of the conversation threads. It looks like the context API isn't paginated. But I might be being thick. https://mastodon.social/users/Edent/statuses/109347587353822637
│       ├── 2022-11-15 11:36 bensb: @Edent Excellent project. You might have seen, but there's also this feature request for better 🧵 handling: https://github.com/mastodon/mastodon/issues/8615 https://genomic.social/users/bensb/statuses/109347612990393791
│       ├── 2022-11-15 11:39 Edent: Cor! That @katebevan is good for engagement! Look at all those conversations she's kicked off! https://mastodon.social/users/Edent/statuses/109347627634008550
│       │   ├── 2022-11-15 11:58 Edent: Indeed, how could they be?That means that ID of a reply is different depending on where you see it.So the ID of this post is:mastodon. social /@ edent/ 123456But when you see it on your server, it might appear as:your. server /@ edent/ 987654The #MastodonAPI copes with this really well. But it is a mite confusing to get one's head around. https://mastodon.social/users/Edent/statuses/109347703064222520
│       │   │   ├── 2022-11-15 12:02 erincandescent: @Edent the numeric IDs are not part of the protocol - it's all URL based. Pleroma uses UUIDs for example https://queer.af/users/erincandescent/statuses/109347716173491502
│       │   │   │   └── 2022-11-15 12:06 Edent: @erincandescent oh! That's interesting. Thanks. https://mastodon.social/users/Edent/statuses/109347734283971306
</code></pre>

<p>Once you have a tree, you can format the contents however you like.</p>

<h2 id="grab-the-code"><a href="https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/#grab-the-code">Grab the code</a></h2>

<p>You can <a href="https://codeberg.org/edent/Mastodon_Tools">download the code for my Mastodon API tools from CodeBerg</a>. Enjoy!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=44124&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2022/11/getting-started-with-mastodons-conversations-api/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
	</channel>
</rss>
