使用Ghost和Gatsby的终极免费个人博客设置
这些天来,似乎有无穷无尽的工具和平台来创建你自己的博客。然而,很多选项都倾向于非技术性用户,并抽象出所有的定制选项,真正做到自己的东西。
如果你是一个熟悉前端开发的人,要找到一个既能给你带来你想要的控制,又能让管理员不再管理你的博客内容的解决方案,可能会让你感到很沮丧。
进入无头内容管理系统(CMS)。有了无头内容管理系统,你可以获得所有的工具来创建和组织你的内容,同时保持对内容如何传递给读者的100%控制。换句话说,你可以得到CMS的所有后台结构,同时不受限于其僵硬的前端主题和模板。
说到无头CMS系统,我是Ghost的忠实粉丝。Ghost是开源的,使用起来很简单,有很多很棒的API,使它可以灵活地与Gatsby等静态网站建设者一起使用。
在这篇文章中,我将向你展示你如何使用Ghost和Gatsby来获得最终的个人博客设置,让你完全控制你的前端交付,但把所有无聊的内容管理留给Ghost。
哦,而且它的设置和运行是100%免费的。这是因为我们将在本地运行我们的Ghost实例,然后部署到Netlify,利用他们慷慨的免费层。
让我们开始吧!
设置Ghost和Gatsby
我以前写过一篇关于这个问题的入门文章,涵盖了最基本的内容,所以我不会在这里太深入地讨论这些问题。相反,我将专注于运行无头博客时出现的更高级的问题和麻烦。
但是,简而言之,我们需要做的是建立和运行一个基本的设置,以便我们能够工作:
- 安装一个本地版本的Gatsby Starter Blog
- 在本地安装Ghost
- 将源数据从Markdown改为Ghost(将
gatsby-source-file系统换成gatsby-source-ghost)。 - 修改你的
gatsby-node、模板和页面中的GraphQL查询,以符合gatsby-source-ghost的模式。
关于这些步骤的更多细节,你可以查看我以前的文章。
或者你可以直接从这个Github仓库的代码开始。
处理图像问题
解决了基本问题后,我们在本地构建的无头博客中遇到的第一个问题就是如何处理图片。
Ghost默认从自己的服务器上提供图片。因此,当你使用静态网站的无头程序时,你会遇到这样的情况:你的内容是由Netlify这样的边缘供应商建立和提供的,但你的图片仍然是由Ghost服务器提供。
从性能的角度来看,这并不理想,而且这使得你无法在本地建立和部署你的网站(这意味着你必须为Digital Ocean droplet、AWS EC2实例或其他服务器支付月费,以托管你的Ghost实例)。
但是,如果我们能找到另一个解决方案来托管我们的图像&mdash,我们就可以绕过这个问题,值得庆幸的是,Ghost有存储转换器,使你能在云中存储图像。
为了我们的目的,我们将使用AWS S3转换器,它使我们能够在AWS S3和Cloudfront上托管我们的图像,使我们的性能与其他内容相似。
有两个开源的选择:ghost-存储-适配器-s3和ghost-s3-compat。我使用ghost-storage-adapter-s3 ,因为我发现文档更容易遵循,而且最近更新的更多。
也就是说,如果我完全按照文档操作,我得到了一些AWS的错误,所以下面是我遵循的对我有效的过程:
-
在AWS创建一个新的S3桶,并选择禁用静态主机
-
接下来,创建一个新的Cloudfront Distribution,并选择S3 Bucket作为起源。
-
在配置Cloudfront Distribution时,在S3 Bucket Access下:
- 选择 "是,使用OAI(桶可以限制只访问Cloudfront)"
- 创建一个新的OAI
- 最后,选择 "是,更新桶的策略"
然后,你只需要为Ghost创建一个IAM用户,使其能够向新的S3桶写入新的图像。要做到这一点,创建一个新的程序化IAM用户,并将此策略附加到它。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR-S3-BUCKET-NAME"
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:PutObjectVersionAcl",
"s3:DeleteObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::YOUR-S3-BUCKET-NAME/*"
}
]
}
这样,我们的AWS设置就完成了,我们只需要告诉Ghost在那里读写我们的图像,而不是写到它的本地服务器。
要做到这一点,我们需要进入安装Ghost实例的文件夹,打开文件:ghost.development.json 或ghost.production.json. (取决于你目前运行的环境)。
然后我们只需要添加以下内容:
{
"storage": {
"active": "s3",
"s3": {
"accessKeyId": "[key]",
"secretAccessKey": "[secret]",
"region": "[region]",
"bucket": "[bucket]",
"assetHost": "https://[subdomain].example.com", // cloudfront
"forcePathStyle": true,
"acl": "private"
}
}
accessKeyId 和secretAccessKey 的值可以从你的IAM设置中找到,而区域和桶是指你的S3桶的区域和桶的名称。最后,assetHost 是你的Cloudfront分布的URL。
现在,如果你重新启动你的Ghost实例,你会看到你保存的任何新图像都在你的S3桶中,Ghost知道在那里链接它们。(注意:Ghost不会进行追溯性更新,所以一定要在新的Ghost安装后第一时间进行更新,这样你就不必在以后重新上传图片了)。
处理内部链接
有了图片,我们需要考虑的下一个棘手的问题是内部链接。当你在Ghost中编写内容并在帖子和页面中插入链接时,Ghost会自动将网站的URL添加到所有内部链接中。
因此,例如,如果你在你的博客文章中插入一个链接,指向/my-post/ ,Ghost将创建一个链接,指向mysite.com/my-post/。
通常情况下,这不是什么大问题,但对于无头博客来说,这会导致问题。这是因为你的Ghost实例将被托管在与你的前端分开的地方,在我们的例子中,它甚至不能在线访问,因为我们将在本地构建。
这意味着我们将需要检查每一篇博客文章和页面,以纠正任何内部链接。值得庆幸的是,这并不像它听起来那么难。
首先,我们将在一个名为replaceLinks.js 的新文件中添加这个HTML解析脚本,并将其放在一个新的utils文件夹中,即src/utils 。
const url = require(`url`);
const cheerio = require('cheerio');
const replaceLinks = async (htmlInput, siteUrlString) => {
const siteUrl = url.parse(siteUrlString);
const $ = cheerio.load(htmlInput);
const links = $('a');
links.attr('href', function(i, href){
if (href) {
const hrefUrl = url.parse(href);
if (hrefUrl.protocol === siteUrl.protocol && hrefUrl.host === siteUrl.host) {
return hrefUrl.path
}
return href;
}
});
return $.html();
}
module.exports = replaceLinks;
然后,我们将在我们的gatsby-node.js 文件中添加以下内容。
exports.onCreateNode = async ({ actions, node, getNodesByType }) => {
if (node.internal.owner !== `gatsby-source-ghost`) {
return
}
if (node.internal.type === 'GhostPage' || node.internal.type === 'GhostPost') {
const settings = getNodesByType(`GhostSettings`);
actions.createNodeField({
name: 'html',
value: replaceLinks(node.html, settings[0].url),
node
})
}
}
你会看到我们在replaceLinks.js中添加了两个新的包,所以让我们先用NPM安装这些包。
npm install --save url cheerio
在我们的gatsby-node.js 文件中,我们正在钩住Gatsby的onCreateNode,特别是钩住任何由来自gatsby-source-ghost 的数据创建的节点(而不是来自我们配置文件的元数据,我们现在并不关心)。
然后我们检查节点类型,以过滤掉任何不是Ghost Pages或Post的节点(因为只有这些节点的内容中会有链接)。
接下来,我们从Ghost设置中获取Ghost网站的URL,并将其与页面/帖子的HTML内容一起传递给我们的removeLinks 。
在replaceLinks ,我们使用cheerio来解析HTML。然后,我们可以选择这个HTML内容中的所有链接,并通过它们的href 属性进行映射。然后,我们可以检查href 属性是否与Ghost网站的URL相匹配--如果是,我们将把href 属性替换为仅仅是URL路径,也就是我们正在寻找的内部链接(例如,像/my-post/ )。
最后,我们使用Gatsby的createNodeField,通过GraphQL使这个新的HTML内容可用(注意:我们必须这样做,因为Gatsby不允许你在构建的这个阶段覆盖字段)。
现在我们的新HTML内容将在我们的blog-post.js 模板中可用,我们可以通过改变我们的GraphQL查询来访问它:
ghostPost(slug: { eq: $slug }) {
id
title
slug
excerpt
published_at_pretty: published_at(formatString: "DD MMMM, YYYY")
html
meta_title
fields {
html
}
}
有了这些,我们只需要调整模板中的这一部分:
<section
dangerouslySetInnerHTML={{ __html: post.html }}
itemProp="articleBody"
/>
要:
<section
dangerouslySetInnerHTML={{ __html: post.fields.html }}
itemProp="articleBody"
/>
这使得我们所有的内部链接都可以到达,但我们还有一个问题。所有这些链接都是<a>锚标签,而对于Gatsby,我们应该使用GatsbyLink 进行内部链接(以避免页面刷新,并提供一个更无缝的体验)。
值得庆幸的是,有一个Gatsby插件,使这个问题非常容易解决。它叫做gatsby-plugin-catch-links,它寻找任何内部链接,并自动用Gatsby 替换锚标签。
我们需要做的就是用NPM安装它。
npm install --save gatsby-plugin-catch-links
并将gatsby-plugin-catch-links 加入我们的gatsby-config 文件中的插件阵列。
添加模板和样式
现在大的东西在技术上是可行的,但我们错过了Ghost实例中的一些内容。
Gatsby Starter Blog只有一个索引页和一个博客帖子的模板,而Ghost默认有帖子、页面,以及标签和作者的页面。因此,我们需要为每一项创建模板。
作为这个项目的起点,我们可以直接复制和粘贴很多文件到我们的项目中。以下是我们将采取的措施:
- 整个文件夹src/components/common/meta- 我们将把它复制到我们的
src/components文件夹中(所以我们现在将有一个文件夹src/components/meta) - 组件文件Pagination.js和PostCard.js- 我们将把它们复制到我们的
src/components文件夹中。 - 我们将创建一个
src/utils文件夹,并从其src/utils文件夹中添加两个文件:fragments.js和siteConfig.js - 并从他们的
src/templates文件夹中添加以下模板:tag.js、page.js、author.js和post.js
元文件正在向我们的模板添加JSON结构化数据标记。这是Ghost在其平台上默认提供的一个伟大的好处,他们已经将其移植到Gatsby中,作为其启动模板的一部分。
然后,我们采用了Pagination 和PostCard.js 组件,我们可以将其直接放入我们的项目中。有了这些组件,我们可以把模板文件放到我们的项目中,它们就可以工作了。
fragments.js 文件使我们的GraphQL查询对我们的每个页面和模板都变得更干净--我们现在只有一个中央源,用于我们所有的GraphQL查询。siteConfig.js 文件有一些Ghost配置选项,最容易放在一个单独的文件中。
现在我们只需要安装一些npm包,并更新我们的gatsby-node 文件,以使用我们的新模板。
我们需要安装的包是gatsby-awesome-pagination、@tryghost/helpers 和@tryghost/helpers-gatsby 。
所以我们将这样做:
npm install --save gatsby-awesome-pagination @tryghost/helpers @tryghost/helpers-gatsby
然后我们需要对我们的gatsby-node 文件做一些更新。
首先,我们将在文件的顶部添加以下新的导入:
const { paginate } = require(`gatsby-awesome-pagination`);
const { postsPerPage } = require(`./src/utils/siteConfig`);
接下来,在我们的exports.createPages ,我们将把我们的GraphQL查询更新为:
{
allGhostPost(sort: { order: ASC, fields: published_at }) {
edges {
node {
slug
}
}
}
allGhostTag(sort: { order: ASC, fields: name }) {
edges {
node {
slug
url
postCount
}
}
}
allGhostAuthor(sort: { order: ASC, fields: name }) {
edges {
node {
slug
url
postCount
}
}
}
allGhostPage(sort: { order: ASC, fields: published_at }) {
edges {
node {
slug
url
}
}
}
}
这将拉出我们需要的所有GraphQL数据,以便Gatsby根据我们的新模板建立页面。
要做到这一点,我们将提取所有这些查询,并把它们分配给变量:
// Extract query results
const tags = result.data.allGhostTag.edges
const authors = result.data.allGhostAuthor.edges
const pages = result.data.allGhostPage.edges
const posts = result.data.allGhostPost.edges
然后我们将加载我们所有的模板:
// Load templates
const tagsTemplate = path.resolve(`./src/templates/tag.js`)
const authorTemplate = path.resolve(`./src/templates/author.js`)
const pageTemplate = path.resolve(`./src/templates/page.js`)
const postTemplate = path.resolve(`./src/templates/post.js`)
请注意,我们正在用post.js 替换旧的blog-post.js 模板,所以我们可以继续从我们的模板文件夹中删除blog-post.js 。
最后,我们将添加这段代码,从我们的模板和GraphQL数据建立页面:
// Create tag pages
tags.forEach(({ node }) => {
const totalPosts = node.postCount !== null ? node.postCount : 0
// This part here defines, that our tag pages will use
// a `/tag/:slug/` permalink.
const url = `/tag/${node.slug}`
const items = Array.from({length: totalPosts})
// Create pagination
paginate({
createPage,
items: items,
itemsPerPage: postsPerPage,
component: tagsTemplate,
pathPrefix: ({ pageNumber }) => (pageNumber === 0) ? url : `${url}/page`,
context: {
slug: node.slug
}
})
})
// Create author pages
authors.forEach(({ node }) => {
const totalPosts = node.postCount !== null ? node.postCount : 0
// This part here defines, that our author pages will use
// a `/author/:slug/` permalink.
const url = `/author/${node.slug}`
const items = Array.from({length: totalPosts})
// Create pagination
paginate({
createPage,
items: items,
itemsPerPage: postsPerPage,
component: authorTemplate,
pathPrefix: ({ pageNumber }) => (pageNumber === 0) ? url : `${url}/page`,
context: {
slug: node.slug
}
})
})
// Create pages
pages.forEach(({ node }) => {
// This part here defines, that our pages will use
// a `/:slug/` permalink.
node.url = `/${node.slug}/`
createPage({
path: node.url,
component: pageTemplate,
context: {
// Data passed to context is available
// in page queries as GraphQL variables.
slug: node.slug,
},
})
})
// Create post pages
posts.forEach(({ node }) => {
// This part here defines, that our posts will use
// a `/:slug/` permalink.
node.url = `/${node.slug}/`
createPage({
path: node.url,
component: postTemplate,
context: {
// Data passed to context is available
// in page queries as GraphQL variables.
slug: node.slug,
},
})
})
在这里,我们将依次循环浏览我们的标签、作者、页面和帖子。对于我们的页面和帖子,我们只是简单地创建蛞蝓,然后使用该蛞蝓创建一个新页面,并告诉Gatsby使用什么模板。
对于标签和作者页面,我们还将使用gatsby-awesome-pagination ,添加分页信息,这些信息将被传递到页面的pageContext 。
有了这些,我们所有的内容现在都应该被成功建立和显示。但是,我们可以在风格设计方面做一些工作。由于我们直接从Ghost Starter复制了我们的模板,我们也可以使用他们的样式。
并非所有的样式都适用,但为了保持简单,不至于在样式方面陷入困境,我从Ghost的src/styles/app.css中抽取了所有的样式,从Layout部分开始,直到最后。然后你就把这些粘贴到你的src/styles.css 文件的结尾。
请注意所有以kg 开始的样式--这是指Koening,它是Ghost编辑器的名字。这些样式对帖子和页面模板非常重要,因为它们有特定的样式来处理在Ghost编辑器中创建的内容。这些样式确保你在编辑器中写的所有内容被翻译过来,并在你的博客上正确显示。
最后,我们需要我们的page.js 和post.js 文件,以适应我们在上一步的内部链接替换,首先是查询:
Page.js
ghostPage(slug: { eq: $slug } ) {
…GhostPageFields
fields {
html
}
}
Post.js
ghostPost(slug: { eq: $slug } ) {
…GhostPostFields
fields {
html
}
}
然后是我们模板中使用HTML内容的部分。因此,在我们的post.js ,我们将改变。
<section
className="content-body load-external-scripts"
dangerouslySetInnerHTML={{ __html: post.html }} />
到:
<section
className="content-body load-external-scripts"
dangerouslySetInnerHTML={{ __html: post.fields.html }} />
同样地,在我们的page.js 文件中,我们将把page.html 改为page.fields.html 。
动态页面内容
当Ghost作为一个传统的内容管理系统使用时,它的一个缺点是不可能在不进入你的实际主题文件和硬编码的情况下编辑一个页面上的个别内容。
例如,你的网站上有一个部分是行动呼吁或客户评价。如果你想改变这些框中的文字,你将不得不编辑实际的HTML文件。
使用无头文件的一个好处是,我们可以在网站上制作动态内容,并可以用Ghost轻松编辑。我们将通过使用页面来做到这一点,我们将用 "内部 "标签或以# 符号开头的标签来标记。
因此,作为一个例子,让我们进入Ghost的后台,创建一个名为 "消息 "的新页面,输入一些内容,最重要的是,我们将添加标签#message 。
现在让我们回到我们的gatsby-node 文件。目前,我们正在为我们所有的标签和页面建立页面,但如果我们在createPages 中修改我们的GraphQL查询,我们可以排除内部的一切:
allGhostTag(sort: { order: ASC, fields: name }, **filter: {slug: {regex: "/^((?!hash-).)*$/"}}**) {
edges {
node {
slug
url
postCount
}
}
}
//...
allGhostPage(sort: { order: ASC, fields: published_at }, **filter: {tags: {elemMatch: {slug: {regex: "/^((?!hash-).)*$/"}}}}**) {
edges {
node {
slug
url
html
}
}
}
我们正在用重合表达式/^((?!hash-).)*$/ ,添加一个关于tag slugs的过滤器。这个表达式是说要排除任何包括hash- 的标签片段。
现在,我们不会为我们的内部内容创建页面,但我们仍然可以从我们的其他GraphQL查询中访问它。因此,让我们把它添加到我们的index.js 页面,把这个添加到我们的查询中:
query GhostIndexQuery($limit: Int!, $skip: Int!) {
site {
siteMetadata {
title
}
}
message: ghostPage
(tags: {elemMatch: {slug: {eq: "hash-message"}}}) {
fields {
html
}
}
allGhostPost(
sort: { order: DESC, fields: [published_at] },
limit: $limit,
skip: $skip
) {
edges {
node {
...GhostPostFields
}
}
}
}
在这里,我们正在创建一个名为 "消息 "的新查询,通过对标签#message 的特别过滤来寻找我们的内部内容页面。然后,让我们通过向我们的页面添加这个来使用#message页面的内容:
//...
const BlogIndex = ({ data, location, pageContext }) => {
const siteTitle = data.site.siteMetadata?.title || `Title`
const posts = data.allGhostPost.edges
const message = data.message;
//...
return (
<Layout location={location} title={siteTitle}>
<Seo title="All posts" />
<section
dangerouslySetInnerHTML={{
__html: message.fields.html,
}}
/>
)
}
结束语
现在我们已经有了一个非常好的博客设置,但我们可以添加一些最后的修饰:在我们的索引页上分页,网站地图和RSS订阅。
首先,为了添加分页,我们需要将我们的index.js 页面转换成一个模板。我们需要做的就是把index.js文件从我们的src/pages 文件夹中剪切并粘贴到我们的src/templates文件夹中,然后把它添加到我们在gatsby-node.js 中加载模板的部分:
// Load templates
const indexTemplate = path.resolve(`./src/templates/index.js`)
然后我们需要告诉Gatsby用我们的index.js 模板创建我们的索引页,并告诉它创建分页上下文。
总的来说,我们将在创建我们的帖子页面之后添加这段代码:
// Create Index page with pagination
paginate({
createPage,
items: posts,
itemsPerPage: postsPerPage,
component: indexTemplate,
pathPrefix: ({ pageNumber }) => {
if (pageNumber === 0) {
return `/`
} else {
return `/page`
}
},
})
现在,让我们打开我们的index.js 模板,导入我们的分页组件,并将其添加到我们映射帖子的地方下面。
import Pagination from '../components/pagination'
//...
</ol>
<Pagination pageContext={pageContext} />
</Layout>
//...
然后我们只需要把我们的博客文章的链接从:
<Link to={post.node.slug} itemProp="url">
到:
<Link to={`/${post.node.slug}/`} itemProp="url">
这可以防止Gatsby Link在分页上给我们的链接加上前缀--换句话说,如果我们不这样做,第2页的链接会显示为/page/2/my-post/ ,而不是我们想要的/my-post/ 。
完成这些后,让我们来设置我们的RSS提要。这是一个非常简单的步骤,因为我们可以使用Ghost团队的Gatsby启动器中的一个现成的脚本。让我们把他们的文件generate-feed.js复制到我们的src/utils 文件夹。
然后让我们在我们的gatsby-config.js 中使用它,将现有的gatsby-plugin-feed 部分替换为:
{
resolve: `gatsby-plugin-feed`,
options: {
query: `
{
allGhostSettings {
edges {
node {
title
description
}
}
}
}
`,
feeds: [
generateRSSFeed(config),
],
},
}
我们将需要把我们的脚本和我们的siteConfig.js 文件一起导入:
const config = require(`./src/utils/siteConfig`);
const generateRSSFeed = require(`./src/utils/generate-feed`);
//...
最后,我们需要对我们的generate-feed.js 文件做一个重要的补充。就在GraphQL查询和输出字段之后,我们需要添加一个标题字段:
#...
output: `/rss.xml`,
title: "Gatsby Starter Blog RSS Feed",
#...
如果没有这个标题字段,gatsby-plugin-feed 在构建时将会出现错误。
然后,作为最后的收尾工作,让我们通过安装软件包gatsby-plugin-advanced-sitemap ,添加我们的网站地图:
npm install --save gatsby-plugin-advanced-sitemap
并将其添加到我们的gatsby-config.js 文件中:
{
resolve: `gatsby-plugin-advanced-sitemap`,
options: {
query: `
{
allGhostPost {
edges {
node {
id
slug
updated_at
created_at
feature_image
}
}
}
allGhostPage {
edges {
node {
id
slug
updated_at
created_at
feature_image
}
}
}
allGhostTag {
edges {
node {
id
slug
feature_image
}
}
}
allGhostAuthor {
edges {
node {
id
slug
profile_image
}
}
}
}`,
mapping: {
allGhostPost: {
sitemap: `posts`,
},
allGhostTag: {
sitemap: `tags`,
},
allGhostAuthor: {
sitemap: `authors`,
},
allGhostPage: {
sitemap: `pages`,
},
},
exclude: [
`/dev-404-page`,
`/404`,
`/404.html`,
`/offline-plugin-app-shell-fallback`,
],
createLinkInHead: true,
addUncaughtPages: true,
}
}
}
这个查询也来自Ghost团队的Gatsby启动器,它为我们的页面和帖子以及我们的作者和标签页面创建了单独的网站地图。
现在,我们只需要对这个查询做一个小改动,以排除我们的内部内容。与我们在上一步所做的一样,我们需要更新这些查询,以过滤掉包含 "hash-"的标签词:
allGhostPage(filter: {tags: {elemMatch: {slug: {regex: "/^((?!hash-).)*$/"}}}}) {
edges {
node {
id
slug
updated_at
created_at
feature_image
}
}
}
allGhostTag(filter: {slug: {regex: "/^((?!hash-).)*$/"}}) {
edges {
node {
id
slug
feature_image
}
}
}
总结
就这样,你现在有了一个在Gatsby上运行的功能齐全的Ghost博客,你可以从这里进行定制。你可以通过在你的本地主机上运行Ghost来创建所有的内容,然后当你准备部署时,你只需运行。
gatsby build
然后你就可以用他们的命令行工具部署到Netlify。
netlify deploy -p
由于你的内容只存在于你的本地机器上,所以偶尔进行备份也是一个好主意,你可以使用Ghost的导出功能进行备份。
这将你所有的内容导出到一个json文件。注意,它不包括你的图片,但无论如何这些都会被保存在云端,所以你不需要担心备份这些。
我希望你喜欢这个教程,我们涵盖了:
- 设置Ghost和Gatsby。
- 使用存储转换器处理Ghost图片。
- 将Ghost内部链接转换为Gatsby链接。
- 为所有Ghost内容类型添加模板和样式。
- 使用在Ghost中创建的动态内容。
- 设置RSS提要、网站地图和分页。
如果你对进一步探索无头CMS的可能性感兴趣,可以看看我在Epilocal的工作,我在那里使用类似的技术栈为本地新闻和其他独立的在线出版商建立工具。
