Skip to content

Commit cf4626b

Browse files
committed
Initial commit
0 parents  commit cf4626b

16 files changed

+1109
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.DS_Store
2+
.vscode/
3+
__pychache__/
4+
*.pyc

CHANGELOG

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Change Log
2+
All notable changes to this project will be documented in this file.
3+
4+
The format is based on [Keep a Changelog](http://keepachangelog.com/)
5+
and this project adheres to [Semantic Versioning](http://semver.org/).
6+
7+
## [Unreleased] - yyyy-mm-dd
8+
9+
Here we write upgrading notes for brands. It's a team effort to make them as
10+
straightforward as possible.
11+
12+
### Added
13+
- [PROJECTNAME-XXXX](http://tickets.projectname.com/browse/PROJECTNAME-XXXX)
14+
MINOR Ticket title goes here.
15+
- [PROJECTNAME-YYYY](http://tickets.projectname.com/browse/PROJECTNAME-YYYY)
16+
PATCH Ticket title goes here.
17+
18+
### Changed
19+
20+
### Fixed
21+
22+
## [1.0.0] - 2023-07-01
23+
24+
### Added
25+
26+
-
27+
28+
### Changed
29+
30+
### Fixed
31+
32+
- [PROJECTNAME-UUUU](http://tickets.projectname.com/browse/PROJECTNAME-UUUU)
33+
MINOR Fix module foo tests
34+
- [PROJECTNAME-RRRR](http://tickets.projectname.com/browse/PROJECTNAME-RRRR)
35+
MAJOR Module foo's timeline uses the browser timezone for date resolution

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Taichi Maeda
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# `hatena-cli`
2+
3+
Command line utility for Hatena Blog.
4+
5+
`hatena-cli` is designed for Hatena Blog enthusiasts who perfer to keep their files stored locally on their machines.
6+
7+
Here's why `hatena-cli` is better than the traditional copy-paste approach:
8+
9+
1. No body length limits.
10+
11+
You know how Hatena Blog's web editor puts a cap on how much you can write? With `hatena-cli`, that's a thing of the past! You can upload massive Markdown files without any hassle.
12+
13+
2. Say goodbye to manual image uploads.
14+
15+
With the `--with-images` option, `hatena-cli` automatically sniffs out the local images referenced in your Markdown file and uploads them to your Hatena Fotolife storage. It even updates the image paths in the generated HTML file!
16+
17+
Currently, `hatena-cli` only supports Markdown files. But no worries, we're thinking about adding support for other file formats soon.
18+
19+
To get started, make sure you have `pandoc` and `hatena-cli` installed. You can install them by running these commands:
20+
21+
```bash
22+
~$ brew install pandoc
23+
~$ python -m pip install hatena-cli
24+
~$ pandoc --version
25+
~$ hatena --version
26+
```
27+
28+
Before you can dive into the functionality of `hatena-cli`, you need do a bit of setup. Just follow these steps:
29+
30+
```bash
31+
~$ hatena config init
32+
```
33+
34+
If you need more configurations, try these commands:
35+
36+
```bash
37+
~$ hatena config list
38+
~$ hatena config set pandoc:path /usr/local/pandoc
39+
~$ hatena config get pandoc:path
40+
```
41+
42+
Now, let's see `hatena-cli` in action! Check out these examples:
43+
44+
```bash
45+
~$ hatena upload --with-images 'My First Blog' '~/Documents/first-blog.md'
46+
~$ hatena update --with-images 'My First Blog 2' '~/Documents/first-blog2.md' 230805800
47+
~$ hatena delete --with-images 230805800
48+
```
49+
50+
`hatena upload` and `hatena delete` require the blog ID of your entry on Hatena Blog.
51+
52+
To find this blog ID, take a look at the URL of Hatena Blog's web editor. It's the `<blog_id>` part in this format:
53+
54+
`https://blog.hatena.ne.jp/<user>/<domain>/edit?entry=<blog_id>`
55+
56+
Remember, when you use the `--with-images` option with `hatena update`, it deletes all previously referenced images and uploads the new ones from your local Markdown file. It can take some time, so use it wisely. It might be quicker to use the web editor if the changes are small.
57+
58+
Happy blogging!
7.13 KB
Binary file not shown.

dist/hatena_cli-0.1.0.tar.gz

5.42 KB
Binary file not shown.

hatena_cli/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Sets version for the CLI.
2+
# Read by `poetry` and `version_callback` in `./main.py`.
3+
__version__ = "0.1.0"

hatena_cli/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .main import app
2+
3+
# Makes the CLI callable from `python -m`.
4+
# https://typer.tiangolo.com/tutorial/package/#support-python-m-optional
5+
app(prog_name="hatena-cli")

hatena_cli/api.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import os
2+
import base64
3+
import re
4+
import requests
5+
from urllib import parse
6+
from pathlib import Path
7+
from requests_oauthlib import OAuth1
8+
from bs4 import BeautifulSoup
9+
from rich import print
10+
from rich.progress import (
11+
Progress,
12+
SpinnerColumn,
13+
TextColumn,
14+
BarColumn,
15+
TaskProgressColumn,
16+
TimeRemainingColumn,
17+
)
18+
import typer
19+
20+
from .config import get_config
21+
22+
23+
_HATENA_BLOG_URL = f"https://blog.hatena.ne.jp/"
24+
_HATENA_FOTOLIFE_POST_URL = "https://f.hatena.ne.jp/atom/post"
25+
_HATENA_FOTOLIFE_EDIT_URL = "https://f.hatena.ne.jp/atom/edit"
26+
27+
28+
class Spinner(Progress):
29+
def __init__(self):
30+
super().__init__(
31+
SpinnerColumn(),
32+
TextColumn("[progress.description]{task.description}"),
33+
)
34+
35+
36+
class ProgressBar(Progress):
37+
def __init__(self):
38+
super().__init__(
39+
TextColumn("[progress.description]{task.description}"),
40+
BarColumn(),
41+
TaskProgressColumn(),
42+
TimeRemainingColumn(elapsed_when_finished=True),
43+
)
44+
45+
46+
def _convert(blog_path: Path = None):
47+
with Spinner() as progress:
48+
progress.add_task("Converting Markdown to HTML...")
49+
50+
pandoc_path = get_config("path:pandoc")
51+
template_path = get_config("path:tempalte")
52+
os.system(
53+
f"{pandoc_path} --quiet --standalone --template '{template_path}' \
54+
--output '{blog_path.with_suffix('.html')}' '{blog_path}'"
55+
)
56+
57+
58+
def upload_entry(blog_title: str, blog_path: Path, with_images: bool, publish: bool):
59+
_convert(blog_path)
60+
auth = auth.get()
61+
if with_images:
62+
_upload_images(blog_title, blog_path, auth)
63+
_upload_html(blog_title, blog_path, publish, auth)
64+
65+
66+
def _upload_images(blog_title: str, blog_path: Path, auth: OAuth1):
67+
with ProgressBar() as progress:
68+
image_pattern = get_config("image:pattern")
69+
image_replace = get_config("image:replace")
70+
with open(blog_path.with_suffix(".html"), mode="r") as file:
71+
content = file.read()
72+
73+
total = len(re.findall(image_pattern, content))
74+
task = progress.add_task("Uploading images...", total=total)
75+
76+
def callback(match: re.Match):
77+
progress.advance(task, 1)
78+
image_path = parse.unquote(match.group(1))
79+
image_url = _upload_image(blog_title, image_path, auth)
80+
return image_replace.replace(r"\1", image_url)
81+
82+
re.sub(image_pattern, callback, content)
83+
84+
85+
def _upload_image(blog_title: str, image_path: Path, auth: OAuth1) -> Path:
86+
with open(image_path, mode="rb") as file:
87+
content = file.read()
88+
content = base64.b64encode(content).decode()
89+
90+
# Maximum length for <dc:subject /> is 24
91+
res = requests.post(
92+
_HATENA_FOTOLIFE_POST_URL,
93+
auth=auth,
94+
timeout=None,
95+
headers={"Content-Type": "application/xml"},
96+
data=f"""\
97+
<?xml version="1.0"?>
98+
<entry xmlns="http://purl.org/atom/ns#">
99+
<dc:subject>{blog_title[:24]}</dc:subject>
100+
<title>{blog_title}</title>
101+
<content mode="base64" type="image/png">{content}</content>
102+
</entry>""",
103+
)
104+
105+
if res.status_code != 201:
106+
print("[red]Error while uploading image.")
107+
raise typer.Abort()
108+
109+
xml = BeautifulSoup(res.text, features="xml")
110+
url = xml.find("entry").find("hatena:imageurl").text
111+
return Path(url)
112+
113+
114+
def _upload_html(blog_title: str, blog_path: Path, publish: bool, auth: OAuth1):
115+
with Spinner() as progress:
116+
progress.add_task("Uploading HTML...")
117+
118+
with open(blog_path.with_suffix("html"), mode="r") as file:
119+
content = file.read()
120+
121+
username = get_config("blog:username")
122+
domain = get_config("blog:domain")
123+
res = requests.post(
124+
f"{_HATENA_BLOG_URL}/{username}/{domain}/atom/entry",
125+
auth=auth,
126+
timeout=None,
127+
headers={"Content-Type": "application/xml; charset=utf-8"},
128+
data=f"""\
129+
<?xml version="1.0" encoding="utf-8"?>
130+
<entry xmlns="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app">
131+
<title>{blog_title}</title>
132+
<author><name>name</name></author>
133+
<content type="text/html"><![CDATA[{content}]]></content>
134+
<app:control><app:draft>{'no' if publish else 'yes'}</app:draft></app:control>
135+
</entry>""".encode(
136+
"utf-8"
137+
),
138+
)
139+
140+
if res.status_code != 201:
141+
print("[red]Error while uploading HTML.")
142+
raise typer.Abort()
143+
144+
145+
def update_entry(
146+
blog_id: int, blog_title: str, blog_path: Path, with_images: bool, publish: bool
147+
):
148+
_convert(blog_path)
149+
auth = auth.get()
150+
if with_images:
151+
_update_images(blog_id, blog_title, blog_path, auth)
152+
_update_html(blog_id, blog_title, blog_path, publish, auth)
153+
154+
155+
def _update_images(blog_id: int, blog_title: str, blog_path: Path, auth: OAuth1):
156+
with Spinner() as progress:
157+
progress.add_task("Updating images...")
158+
_delete_images(blog_id, auth)
159+
_upload_images(blog_title, blog_path, auth)
160+
161+
162+
def _update_html(
163+
blog_id: int, blog_title: str, blog_path: Path, publish: bool, auth: OAuth1
164+
):
165+
with Spinner() as progress:
166+
progress.add_task("Updating HTML...")
167+
168+
with open(blog_path.with_suffix("html"), mode="r") as file:
169+
content = file.read()
170+
171+
username = get_config("blog:username")
172+
domain = get_config("blog:domain")
173+
res = requests.put(
174+
f"{_HATENA_BLOG_URL}/{username}/{domain}/atom/entry/{blog_id}",
175+
auth=auth,
176+
timeout=None,
177+
headers={"Content-Type": "application/xml; charset=utf-8"},
178+
data=f"""\
179+
<?xml version="1.0" encoding="utf-8"?>
180+
<entry xmlns="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app">
181+
<title>{blog_title}</title>
182+
<author><name>name</name></author>
183+
<content type="text/html"><![CDATA[{content}]]></content>
184+
<app:control><app:draft>{'no' if publish else 'yes'}</app:draft></app:control>
185+
</entry>""".encode(
186+
"utf-8"
187+
),
188+
)
189+
if res.status_code != 200:
190+
print("[red]Error while updating HTML.")
191+
raise typer.Abort()
192+
193+
194+
def delete_entry(blog_id: int, with_images: bool):
195+
auth = auth.get()
196+
if with_images:
197+
_delete_images(blog_id, auth)
198+
_delete_html(blog_id, auth)
199+
200+
201+
def _delete_images(blog_id: int, auth: OAuth1):
202+
with ProgressBar() as progress:
203+
username = get_config("blog:username")
204+
domain = get_config("blog:domain")
205+
res = requests.get(
206+
f"{_HATENA_BLOG_URL}/{username}/{domain}/{blog_id}",
207+
auth=auth,
208+
timeout=None,
209+
)
210+
211+
if res.status_code != 200:
212+
print("[red]Error while downloading HTML.")
213+
raise typer.Abort()
214+
215+
xml = BeautifulSoup(res.text, features="xml")
216+
content = xml.find("entry").find("hatena:formatted-content").text
217+
content = parse.unquote(content)
218+
219+
image_pattern = get_config("image:pattern")
220+
total = len(re.findall(image_pattern, content))
221+
task = progress.add_task("Deleting images...", total=total)
222+
223+
def callback(match: re.Match):
224+
progress.advance(task, 1)
225+
image_path = parse.unquote(match.group(1))
226+
image_id = os.path.splitext(os.path.basename(image_path))[0]
227+
res = requests.delete(
228+
f"{_HATENA_FOTOLIFE_EDIT_URL}/{image_id}", auth=auth, timeout=None
229+
)
230+
if res.status_code != 200:
231+
print("[red]Error while deleting image.")
232+
raise typer.Abort()
233+
234+
re.sub(image_pattern, callback, content)
235+
236+
237+
def _delete_html(blog_id: int, auth: OAuth1):
238+
with Spinner() as progress:
239+
progress.add_task("Deleting HTML...")
240+
241+
username = get_config("blog:username")
242+
domain = get_config("blog:domain")
243+
res = requests.delete(
244+
f"{_HATENA_BLOG_URL}/{username}/{domain}/{blog_id}",
245+
auth=auth,
246+
timeout=None,
247+
)
248+
249+
if res.status_code != 200:
250+
print("[red]Error while deleting HTML.")
251+
raise typer.Abort()

0 commit comments

Comments
 (0)