init
This commit is contained in:
commit
493d23c2f3
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@ -0,0 +1,8 @@
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.v]
|
||||
indent_style = tab
|
7
.gitattributes
vendored
Normal file
7
.gitattributes
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
* text=auto eol=lf
|
||||
*.bat eol=crlf
|
||||
|
||||
**/*.v linguist-language=V
|
||||
**/*.vv linguist-language=V
|
||||
**/*.vsh linguist-language=V
|
||||
**/v.mod linguist-language=V
|
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
# Binaries for programs and plugins
|
||||
habraview
|
||||
v
|
||||
*.exe
|
||||
*.exe~
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# Ignore binary output folders
|
||||
bin/
|
||||
|
||||
# Ignore common editor/system specific metadata
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# ENV
|
||||
.env
|
7
Makefile
Normal file
7
Makefile
Normal file
@ -0,0 +1,7 @@
|
||||
all: build
|
||||
|
||||
build:
|
||||
v -o habraview src
|
||||
|
||||
prod:
|
||||
v -o habraview -prod -cc clang -compress src
|
51
README.md
Normal file
51
README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# habraview
|
||||
|
||||
Спартанский микрофронтенд habr.com для возможности архивации страниц
|
||||
инструментами вроде ArchiveBox и т.п.
|
||||
|
||||
Современные фронтенды к Хабру не позволяют сохранить содержимое страницы
|
||||
без искажений и в полном объёме. Всё обмазано JavaScript с ленивой загрузкой
|
||||
изображений, отчего картинки на сохранённой странице только заблюренные, а
|
||||
возможность архивации комментариев полностью отсутствует. Страницы
|
||||
альтернативного фронтенда geekr.vercel.app вовсе непригодны для архивации.
|
||||
Поэтому появился этот костыль.
|
||||
|
||||
Фичи:
|
||||
|
||||
* Умеет отображать страницу в минимальном сносном CSS.
|
||||
* Отображает все комментарии (отрисовка дерева не удалась, но и так сойдёт).
|
||||
* Решает проблему с заблюренными изображениями.
|
||||
|
||||
Работает только с `article`, то есть новостные посты и статьи из `sandbox`.
|
||||
|
||||
# Как пользоваться
|
||||
|
||||
`habraview` это веб-приложение. Просто запускаем файл:
|
||||
|
||||
```
|
||||
./habraview
|
||||
```
|
||||
|
||||
Приложение будет по умолчанию будет слушать на 8888 порту. Чтобы получить
|
||||
страницу, открываем в брайзере:
|
||||
|
||||
```
|
||||
http://localhost:8888?url=https://habr.com/ru/articles/853062/
|
||||
```
|
||||
|
||||
Адрес статьи на Хабре можно передать целиком как значение quey-параметра `url`
|
||||
или как `id`:
|
||||
|
||||
```
|
||||
http://localhost:8888?id=853062
|
||||
```
|
||||
|
||||
Теперь на эту страницу можно натравить архиватор веб-страниц.
|
||||
|
||||
# Компиляция
|
||||
|
||||
Нужны компиляторы `gcc` и [v](https://vlang.io):
|
||||
|
||||
```
|
||||
v -prod -cflags -static -cflags -s .
|
||||
```
|
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.2 KiB |
14
assets/habr-fixer.js
Normal file
14
assets/habr-fixer.js
Normal file
@ -0,0 +1,14 @@
|
||||
(function(document)
|
||||
{
|
||||
const onDOMBuild = () =>
|
||||
{
|
||||
// Fix blurred images
|
||||
const images = document.querySelectorAll('img[data-blurred="true"]');
|
||||
for (let image of images)
|
||||
{
|
||||
image.src = image.dataset.src;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', onDOMBuild);
|
||||
})(document);
|
9
assets/highlight.min.css
vendored
Normal file
9
assets/highlight.min.css
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/*!
|
||||
Theme: Default
|
||||
Description: Original highlight.js style
|
||||
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
|
||||
Maintainer: @highlightjs/core-team
|
||||
Website: https://highlightjs.org/
|
||||
License: see project LICENSE
|
||||
Touched: 2021
|
||||
*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
1213
assets/highlight.min.js
vendored
Normal file
1213
assets/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
89
assets/style.css
Normal file
89
assets/style.css
Normal file
@ -0,0 +1,89 @@
|
||||
body {
|
||||
background-color: #F1F5F9;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
header, main, footer {
|
||||
margin: 0 auto;
|
||||
max-width: 1080px;
|
||||
}
|
||||
article {
|
||||
background-color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid #E6E7E9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
pre code.hljs {
|
||||
border-radius: 8px;
|
||||
}
|
||||
article img {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #bbbbbb;
|
||||
padding-left: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
figure {
|
||||
text-align: center;
|
||||
}
|
||||
figcaption {
|
||||
text-align: center;
|
||||
opacity: 60%;
|
||||
}
|
||||
#date-published {
|
||||
opacity: 60%;
|
||||
}
|
||||
#article-title {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#tags {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
#hubs {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
span.comma:not(:empty) ~ span.comma:not(:empty):before {
|
||||
content: ", ";
|
||||
}
|
||||
#comments {
|
||||
margin-top: 1rem;
|
||||
background-color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid #E6E7E9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.comment {
|
||||
padding: 0.5rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
.comment-header {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.replies-list {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
ul.inline-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul.inline-list li {
|
||||
display: inline;
|
||||
list-style: none;
|
||||
}
|
||||
table, th, td {
|
||||
border: 1px solid #E6E7E9;
|
||||
border-collapse: collapse;
|
||||
padding: 2px 16px;
|
||||
}
|
||||
:target {
|
||||
border-left: 4px solid black;
|
||||
padding: 0 1rem !important;
|
||||
background-color: #f8f8f8;
|
||||
.comment-header {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
}
|
21
habr/api.v
Normal file
21
habr/api.v
Normal file
@ -0,0 +1,21 @@
|
||||
module habr
|
||||
|
||||
import net.http
|
||||
|
||||
pub struct Habr {
|
||||
baseurl string = $d('habr_baseurl', 'https://habr.com')
|
||||
}
|
||||
|
||||
pub fn Habr.new() Habr {
|
||||
return Habr{}
|
||||
}
|
||||
|
||||
pub fn (h Habr) get_article(id int) !string {
|
||||
response := http.get('${h.baseurl}/kek/v2/articles/${id}/') or { return err }
|
||||
return response.body
|
||||
}
|
||||
|
||||
pub fn (h Habr) get_article_comments(id int) !string {
|
||||
response := http.get('${h.baseurl}/kek/v2/articles/${id}/comments/') or { return err }
|
||||
return response.body
|
||||
}
|
65
habr/article.v
Normal file
65
habr/article.v
Normal file
@ -0,0 +1,65 @@
|
||||
module habr
|
||||
|
||||
import json
|
||||
import veb { RawHtml }
|
||||
|
||||
pub struct Article {
|
||||
pub:
|
||||
published_at string @[json: timePublished]
|
||||
title string @[json: titleHtml]
|
||||
text RawHtml @[json: textHtml]
|
||||
hubs []Hub
|
||||
tags []Tag
|
||||
}
|
||||
|
||||
pub fn Article.parse(input string) Article {
|
||||
return json.decode(Article, input) or { Article{} }
|
||||
}
|
||||
|
||||
pub struct Hub {
|
||||
pub:
|
||||
id string
|
||||
alias string
|
||||
title string @[json: titleHtml]
|
||||
}
|
||||
|
||||
pub struct Tag {
|
||||
pub:
|
||||
title string @[json: titleHtml]
|
||||
}
|
||||
|
||||
pub struct Comment {
|
||||
pub:
|
||||
id string
|
||||
parent_id string @[json: parentId]
|
||||
replies_ids []string @[json: children]
|
||||
level int
|
||||
author CommentAuthor
|
||||
message RawHtml
|
||||
}
|
||||
|
||||
pub struct CommentAuthor {
|
||||
pub:
|
||||
alias string
|
||||
}
|
||||
|
||||
struct CommentsMapped {
|
||||
mut:
|
||||
items map[string]Comment @[json: comments]
|
||||
}
|
||||
|
||||
pub struct Comments {
|
||||
pub mut:
|
||||
items []Comment
|
||||
mut:
|
||||
idx int
|
||||
}
|
||||
|
||||
pub fn Comments.parse(input string) Comments {
|
||||
mut comments := Comments{}
|
||||
mapped := json.decode(CommentsMapped, input) or { CommentsMapped{} }
|
||||
for _, v in mapped.items {
|
||||
comments.items << v
|
||||
}
|
||||
return comments
|
||||
}
|
12
habr/util.v
Normal file
12
habr/util.v
Normal file
@ -0,0 +1,12 @@
|
||||
module habr
|
||||
|
||||
import regex
|
||||
|
||||
pub fn get_id_from_url(url string) ?string {
|
||||
mut re := regex.regex_opt(r'/\d+/?$') or { return none }
|
||||
begin, end := re.find(url)
|
||||
if begin > 0 && end > 0 {
|
||||
return url[begin..end].trim('/')
|
||||
}
|
||||
return none
|
||||
}
|
67
habraview.v
Normal file
67
habraview.v
Normal file
@ -0,0 +1,67 @@
|
||||
module main
|
||||
|
||||
import cli
|
||||
import habr
|
||||
import os
|
||||
import veb
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
}
|
||||
|
||||
struct Response {
|
||||
msg string
|
||||
}
|
||||
|
||||
@[get]
|
||||
fn (a &App) index(mut ctx Context) veb.Result {
|
||||
article_id := ctx.query['id'] or { habr.get_id_from_url(ctx.query['url']) or { '' } }
|
||||
client := habr.Habr.new()
|
||||
raw_article := client.get_article(article_id.int()) or {
|
||||
return ctx.json(Response{ msg: err.str() })
|
||||
}
|
||||
raw_comments := client.get_article_comments(article_id.int()) or {
|
||||
return ctx.json(Response{ msg: err.str() })
|
||||
}
|
||||
article := habr.Article.parse(raw_article)
|
||||
comments := habr.Comments.parse(raw_comments)
|
||||
return $veb.html()
|
||||
}
|
||||
|
||||
fn runserver(port int) ! {
|
||||
os.chdir(os.dir(@FILE))!
|
||||
mut app := &App{}
|
||||
app.handle_static('assets', false)!
|
||||
app.serve_static('/favicon.ico', 'assets/favicon.ico')!
|
||||
veb.run[App, Context](mut app, port)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := cli.Command{
|
||||
name: 'habraview'
|
||||
description: 'Habr.com posts viewer.'
|
||||
version: $d('habraview_version', '0.0.0')
|
||||
defaults: struct {
|
||||
man: false
|
||||
}
|
||||
execute: fn (cmd cli.Command) ! {
|
||||
port := cmd.flags.get_int('port') or { 8080 }
|
||||
runserver(port)!
|
||||
}
|
||||
flags: [
|
||||
cli.Flag{
|
||||
flag: .int
|
||||
name: 'port'
|
||||
abbrev: 'p'
|
||||
description: 'Listen port [default: 8888].'
|
||||
default_value: ['8888']
|
||||
},
|
||||
]
|
||||
}
|
||||
app.setup()
|
||||
app.parse(os.args)
|
||||
}
|
16
templates/comment.html
Normal file
16
templates/comment.html
Normal file
@ -0,0 +1,16 @@
|
||||
<div class="comment" id="@{comment.id}">
|
||||
<p class="comment-header">[<a href="#@{comment.id}">@{comment.id}</a>] <strong>@{comment.author.alias}</strong>
|
||||
@if comment.parent_id != ''
|
||||
<span> in reply to <a href="#@{comment.parent_id}">#@{comment.parent_id}</a></span>
|
||||
@end
|
||||
</p>
|
||||
<p>@{comment.message}</p>
|
||||
@if comment.replies_ids.len > 0
|
||||
<div class="replies-list">
|
||||
<span>Replies: </span>
|
||||
@for reply_id in comment.replies_ids
|
||||
<span><a href="#@{reply_id}">#@{reply_id}</a></span>
|
||||
@end
|
||||
</div>
|
||||
@end
|
||||
</div>
|
56
templates/index.html
Normal file
56
templates/index.html
Normal file
@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@{article.title}</title>
|
||||
@css '/assets/style.css'
|
||||
@css '/assets/highlight.min.css'
|
||||
</head>
|
||||
<body>
|
||||
<nav></nav>
|
||||
<header>
|
||||
<h1 id="article-title">@{article.title}</h1>
|
||||
<p id="date-published">@{article.published_at}</p>
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
@{article.text}
|
||||
</article>
|
||||
<div id="tags">
|
||||
@if article.tags.len > 0
|
||||
<span><strong>Тэги: </strong></span>
|
||||
@for tag in article.tags
|
||||
<span class="comma">
|
||||
<a href="https://habr.com/ru/search/?target_type=posts&order=relevance&q=%5B@{tag.title}%5D">
|
||||
@{tag.title}
|
||||
</a>
|
||||
</span>
|
||||
@end
|
||||
@end
|
||||
</div>
|
||||
<div id="hubs">
|
||||
@if article.hubs.len > 0
|
||||
<span><strong>Хабы: </strong></span>
|
||||
@for hub in article.hubs
|
||||
<span class="comma">
|
||||
<a href="https://habr.com/ru/hubs/@{hub.alias}/articles/">
|
||||
@{hub.title}
|
||||
</a>
|
||||
</span>
|
||||
@end
|
||||
@end
|
||||
</div>
|
||||
<div id="comments">
|
||||
<p><strong>Комментарии</strong> (@{comments.items.len})</p>
|
||||
@for comment in comments.items
|
||||
@include 'comment.html'
|
||||
@end
|
||||
<div>
|
||||
</main>
|
||||
<footer>
|
||||
</footer>
|
||||
@js '/assets/habr-fixer.js'
|
||||
@js '/assets/highlight.min.js'
|
||||
<script>hljs.highlightAll();</script>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user