add mercury parser

This commit is contained in:
刘浩远 2020-08-05 19:03:05 +08:00
parent 52e0c1b90c
commit 38646b227c
12 changed files with 111 additions and 48 deletions

View File

@ -72,6 +72,7 @@ npm run package-win
- [Redux](https://github.com/reduxjs/redux) - [Redux](https://github.com/reduxjs/redux)
- [Fluent UI](https://github.com/microsoft/fluentui) - [Fluent UI](https://github.com/microsoft/fluentui)
- [NeDB](https://github.com/louischatriot/nedb) - [NeDB](https://github.com/louischatriot/nedb)
- [Mercury Parser](https://github.com/postlight/mercury-parser)
### License ### License

View File

@ -3,13 +3,14 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src-elem 'sha256-HLvh6tC4kZKt81b6Yi9wjdyXvuwO6InxwRG96ZZjHrw='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*"> content="default-src 'none'; script-src-elem 'sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=' 'sha256-q3VKKMMe+ucICfT8N3WHLJdQovcvvHc0HbJ5N9uA3+w='; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:">
<title>Article</title> <title>Article</title>
<link rel="stylesheet" href="article.css" /> <link rel="stylesheet" href="article.css" />
<script integrity="sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=" src="mercury.web.js"></script>
</head> </head>
<body> <body>
<div id="main"></div> <div id="main"></div>
<script integrity="sha256-HLvh6tC4kZKt81b6Yi9wjdyXvuwO6InxwRG96ZZjHrw=" src="article.js"></script> <script integrity="sha256-q3VKKMMe+ucICfT8N3WHLJdQovcvvHc0HbJ5N9uA3+w=" src="article.js"></script>
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash --> <!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
</body> </body>
</html> </html>

View File

@ -2,18 +2,33 @@ function get(name) {
if (name = (new RegExp('[?&]' + encodeURIComponent(name) + '=([^&]*)')).exec(location.search)) if (name = (new RegExp('[?&]' + encodeURIComponent(name) + '=([^&]*)')).exec(location.search))
return decodeURIComponent(name[1]); return decodeURIComponent(name[1]);
} }
async function getArticle(url) {
let article = get("a")
if (get("m") === "1") {
return (await Mercury.parse(url, {html: article})).content || ""
} else {
return article
}
}
document.documentElement.style.fontSize = get("s") + "px" document.documentElement.style.fontSize = get("s") + "px"
let html = get("h") let url = get("u")
let domParser = new DOMParser() getArticle(url).then(article => {
let dom = domParser.parseFromString(html, "text/html") let domParser = new DOMParser()
let baseEl = dom.createElement('base') let dom = domParser.parseFromString(get("h"), "text/html")
baseEl.setAttribute('href', get("u").split("/").slice(0, 3).join("/")) dom.getElementsByTagName("article")[0].innerHTML = article
dom.head.append(baseEl) let baseEl = dom.createElement('base')
for (let e of dom.querySelectorAll("*[src]")) { baseEl.setAttribute('href', url.split("/").slice(0, 3).join("/"))
e.src = e.src dom.head.append(baseEl)
} for (let s of dom.getElementsByTagName("script")) {
for (let s of dom.querySelectorAll("script")) { s.parentNode.removeChild(s)
s.parentNode.removeChild(s) }
} for (let e of dom.querySelectorAll("*[src]")) {
let main = document.getElementById("main") e.src = e.src
main.innerHTML = dom.body.innerHTML }
for (let e of dom.querySelectorAll("*[href]")) {
e.href = e.href
}
let main = document.getElementById("main")
main.innerHTML = dom.body.innerHTML
})

1
dist/article/mercury.web.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/icons/fabric-icons-10-c4ded8e4.woff vendored Normal file

Binary file not shown.

View File

@ -27,6 +27,8 @@ type ArticleProps = {
type ArticleState = { type ArticleState = {
fontSize: number fontSize: number
loadWebpage: boolean loadWebpage: boolean
loadFull: boolean
fullContent: string
loaded: boolean loaded: boolean
error: boolean error: boolean
errorDescription: string errorDescription: string
@ -35,11 +37,13 @@ type ArticleState = {
class Article extends React.Component<ArticleProps, ArticleState> { class Article extends React.Component<ArticleProps, ArticleState> {
webview: Electron.WebviewTag webview: Electron.WebviewTag
constructor(props) { constructor(props: ArticleProps) {
super(props) super(props)
this.state = { this.state = {
fontSize: this.getFontSize(), fontSize: this.getFontSize(),
loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage, loadWebpage: props.source.openTarget === SourceOpenTarget.Webpage,
loadFull: props.source.openTarget === SourceOpenTarget.FullContent,
fullContent: "",
loaded: false, loaded: false,
error: false, error: false,
errorDescription: "", errorDescription: "",
@ -47,6 +51,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
window.utils.addWebviewContextListener(this.contextMenuHandler) window.utils.addWebviewContextListener(this.contextMenuHandler)
window.utils.addWebviewKeydownListener(this.keyDownHandler) window.utils.addWebviewKeydownListener(this.keyDownHandler)
window.utils.addWebviewErrorListener(this.webviewError) window.utils.addWebviewErrorListener(this.webviewError)
if (props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull()
} }
getFontSize = () => { getFontSize = () => {
@ -87,6 +92,13 @@ class Article extends React.Component<ArticleProps, ArticleState> {
iconProps: { iconName: this.props.item.hidden ? "View" : "Hide3" }, iconProps: { iconName: this.props.item.hidden ? "View" : "Hide3" },
onClick: () => { this.props.toggleHidden(this.props.item) } onClick: () => { this.props.toggleHidden(this.props.item) }
}, },
{
key: "fontMenu",
text: intl.get("article.fontSize"),
iconProps: { iconName: "FontSize" },
disabled: this.state.loadWebpage,
subMenuProps: this.fontMenuProps()
},
{ {
key: "divider_1", key: "divider_1",
itemType: ContextualMenuItemType.Divider, itemType: ContextualMenuItemType.Divider,
@ -117,6 +129,9 @@ class Article extends React.Component<ArticleProps, ArticleState> {
case "l": case "L": case "l": case "L":
this.toggleWebpage() this.toggleWebpage()
break break
case "w": case "W":
this.toggleFull()
break
default: default:
const keyboardEvent = new KeyboardEvent("keydown", { const keyboardEvent = new KeyboardEvent("keydown", {
code: input.code, code: input.code,
@ -145,6 +160,8 @@ class Article extends React.Component<ArticleProps, ArticleState> {
if (this.webview) { if (this.webview) {
this.setState({loaded: false, error: false}) this.setState({loaded: false, error: false})
this.webview.reload() this.webview.reload()
} else if (this.state.loadFull) {
this.loadFull()
} }
} }
@ -152,17 +169,23 @@ class Article extends React.Component<ArticleProps, ArticleState> {
let webview = document.getElementById("article") as Electron.WebviewTag let webview = document.getElementById("article") as Electron.WebviewTag
if (webview != this.webview) { if (webview != this.webview) {
this.webview = webview this.webview = webview
webview.focus() if (webview) {
this.setState({loaded: false, error: false}) webview.focus()
webview.addEventListener("did-stop-loading", this.webviewLoaded) this.setState({loaded: false, error: false})
let card = document.querySelector(`#refocus div[data-iid="${this.props.item._id}"]`) as HTMLElement webview.addEventListener("did-stop-loading", this.webviewLoaded)
// @ts-ignore let card = document.querySelector(`#refocus div[data-iid="${this.props.item._id}"]`) as HTMLElement
if (card) card.scrollIntoViewIfNeeded() // @ts-ignore
if (card) card.scrollIntoViewIfNeeded()
}
} }
} }
componentDidUpdate = (prevProps: ArticleProps) => { componentDidUpdate = (prevProps: ArticleProps) => {
if (prevProps.item._id != this.props.item._id) { if (prevProps.item._id != this.props.item._id) {
this.setState({loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage}) this.setState({
loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage,
loadFull: this.props.source.openTarget === SourceOpenTarget.FullContent,
})
if (this.props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull()
} }
this.componentDidMount() this.componentDidMount()
} }
@ -174,17 +197,39 @@ class Article extends React.Component<ArticleProps, ArticleState> {
toggleWebpage = () => { toggleWebpage = () => {
if (this.state.loadWebpage) { if (this.state.loadWebpage) {
this.setState({loadWebpage: false}) this.setState({ loadWebpage: false })
} else if (this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://")) { } else if (this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://")) {
this.setState({loadWebpage: true}) this.setState({ loadWebpage: true, loadFull: false })
} }
} }
articleView = () => "article/article.html?h=" + encodeURIComponent(renderToString(<> toggleFull = () => {
<p className="title">{this.props.item.title}</p> if (this.state.loadFull) {
<p className="date">{this.props.item.date.toLocaleString(this.props.locale, {hour12: !this.props.locale.startsWith("zh")})}</p> this.setState({ loadFull: false })
<article dangerouslySetInnerHTML={{__html: this.props.item.content}}></article> } else if (this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://")) {
</>)) + `&s=${this.state.fontSize}&u=${this.props.item.link}` this.setState({ loadFull: true, loadWebpage: false })
this.loadFull()
}
}
loadFull = async () => {
this.setState({ fullContent: "", loaded: false, error: false })
try {
const html = await (await fetch(this.props.item.link)).text()
this.setState({ fullContent: html })
} catch {
this.setState({ loaded: true, error: true, errorDescription: "MERCURY_PARSER_FAILURE" })
}
}
articleView = () => {
const a = encodeURIComponent(this.state.loadFull ? this.state.fullContent : this.props.item.content)
const h = encodeURIComponent(renderToString(<>
<p className="title">{this.props.item.title}</p>
<p className="date">{this.props.item.date.toLocaleString(this.props.locale, {hour12: !this.props.locale.startsWith("zh")})}</p>
<article></article>
</>))
return `article/article.html?a=${a}&h=${h}&s=${this.state.fontSize}&u=${this.props.item.link}&m=${this.state.loadFull?1:0}`
}
render = () => ( render = () => (
<FocusZone className="article"> <FocusZone className="article">
@ -211,11 +256,10 @@ class Article extends React.Component<ArticleProps, ArticleState> {
iconProps={{iconName: this.props.item.starred ? "FavoriteStarFill" : "FavoriteStar"}} iconProps={{iconName: this.props.item.starred ? "FavoriteStarFill" : "FavoriteStar"}}
onClick={() => this.props.toggleStarred(this.props.item)} /> onClick={() => this.props.toggleStarred(this.props.item)} />
<CommandBarButton <CommandBarButton
title={intl.get("article.fontSize")} title={intl.get("article.loadFull")}
disabled={this.state.loadWebpage} className={this.state.loadFull ? "active" : ""}
iconProps={{iconName: "FontSize"}} iconProps={{iconName: "RawSource"}}
menuIconProps={{style: {display: "none"}}} onClick={this.toggleFull} />
menuProps={this.fontMenuProps()} />
<CommandBarButton <CommandBarButton
title={intl.get("article.loadWebpage")} title={intl.get("article.loadWebpage")}
className={this.state.loadWebpage ? "active" : ""} className={this.state.loadWebpage ? "active" : ""}
@ -234,13 +278,13 @@ class Article extends React.Component<ArticleProps, ArticleState> {
onClick={this.props.dismiss} /> onClick={this.props.dismiss} />
</Stack> </Stack>
</Stack> </Stack>
<webview {(!this.state.loadFull || this.state.fullContent) && <webview
id="article" id="article"
className={this.state.error ? "error" : ""} className={this.state.error ? "error" : ""}
key={this.props.item._id + (this.state.loadWebpage ? "_" : "")} key={this.props.item._id + (this.state.loadWebpage ? "_" : "")}
src={this.state.loadWebpage ? this.props.item.link : this.articleView()} src={this.state.loadWebpage ? this.props.item.link : this.articleView()}
webpreferences="contextIsolation,disableDialogs,autoplayPolicy=document-user-activation-required" webpreferences="contextIsolation,disableDialogs,autoplayPolicy=document-user-activation-required"
partition={this.state.loadWebpage ? "sandbox" : undefined} /> partition={this.state.loadWebpage ? "sandbox" : undefined} />}
{this.state.error && ( {this.state.error && (
<Stack className="error-prompt" verticalAlign="center" horizontalAlign="center" tokens={{childrenGap: 12}}> <Stack className="error-prompt" verticalAlign="center" horizontalAlign="center" tokens={{childrenGap: 12}}>
<Icon iconName="HeartBroken" style={{fontSize: 32}} /> <Icon iconName="HeartBroken" style={{fontSize: 32}} />

View File

@ -30,16 +30,15 @@ export namespace Card {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
switch (props.source.openTarget) { switch (props.source.openTarget) {
case SourceOpenTarget.Local:
case SourceOpenTarget.Webpage: {
props.markRead(props.item)
props.showItem(props.feedId, props.item)
break
}
case SourceOpenTarget.External: { case SourceOpenTarget.External: {
openInBrowser(props, e) openInBrowser(props, e)
break break
} }
default: {
props.markRead(props.item)
props.showItem(props.feedId, props.item)
break
}
} }
} }

View File

@ -121,6 +121,7 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
sourceOpenTargetChoices = (): IChoiceGroupOption[] => [ sourceOpenTargetChoices = (): IChoiceGroupOption[] => [
{ key: String(SourceOpenTarget.Local), text: intl.get("sources.rssText") }, { key: String(SourceOpenTarget.Local), text: intl.get("sources.rssText") },
{ key: String(SourceOpenTarget.FullContent), text: intl.get("article.loadFull") },
{ key: String(SourceOpenTarget.Webpage), text: intl.get("sources.loadWebpage") }, { key: String(SourceOpenTarget.Webpage), text: intl.get("sources.loadWebpage") },
{ key: String(SourceOpenTarget.External), text: intl.get("openExternal") } { key: String(SourceOpenTarget.External), text: intl.get("openExternal") }
] ]

View File

@ -57,7 +57,6 @@ export class WindowManager {
fullscreenable: false, fullscreenable: false,
show: false, show: false,
webPreferences: { webPreferences: {
sandbox: true,
webviewTag: true, webviewTag: true,
enableRemoteModule: false, enableRemoteModule: false,
contextIsolation: true, contextIsolation: true,

View File

@ -64,6 +64,7 @@
"unstar": "Remove star", "unstar": "Remove star",
"fontSize": "Font size", "fontSize": "Font size",
"loadWebpage": "Load webpage", "loadWebpage": "Load webpage",
"loadFull": "Load full content",
"notify": "Notify if fetched in background", "notify": "Notify if fetched in background",
"dontNotify": "Don't notify" "dontNotify": "Don't notify"
}, },

View File

@ -64,6 +64,7 @@
"unstar": "取消星标", "unstar": "取消星标",
"fontSize": "字体大小", "fontSize": "字体大小",
"loadWebpage": "加载网页", "loadWebpage": "加载网页",
"loadFull": "抓取全文",
"notify": "后台抓取时发送通知", "notify": "后台抓取时发送通知",
"dontNotify": "不发送通知" "dontNotify": "不发送通知"
}, },

View File

@ -6,8 +6,8 @@ import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNR
import { saveSettings } from "./app" import { saveSettings } from "./app"
import { SourceRule } from "./rule" import { SourceRule } from "./rule"
export enum SourceOpenTarget { export const enum SourceOpenTarget {
Local, Webpage, External Local, Webpage, External, FullContent
} }
export class RSSSource { export class RSSSource {