openvibers/openvibe.blog Commits
Blog desks: cited items first; a weekly Crowdfunding digest (#60)
Changes
@@@ -170,6 +170,7 @@ indexed (`noindex`, not in the sitemaps).| Food safety digest | `fda-food-recalls` | daily, 06:00 UTC (1 item at least) || Deals digest | `dealnews-daily` | daily, 07:00 UTC (5 items at least) || Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |++| Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) || Developer digest | `nodejs-blog` | Mondays, for the week before || Is This Real? (satire) | none | daily, 14:00 UTC |@@@ -33,6 +33,11 @@ const DESKS = [about: 'New and changed VPS and cloud plans in providers\' own price lists (Akamai/Linode, AWS Lightsail, OVHcloud, Vultr) and low-end hosting offers, week by week.',categories: ['hosting'], sources: ['linode-types', 'aws-lightsail-bundles', 'ovhcloud-vps', 'vultr-plans', 'lowendbox-posts', 'lowendtalk-offers'],},++ {++ key: 'crowdfunding', title: 'Crowdfunding digest', every: 'week', hour: 7, min: 3, max: 30,++ about: 'New and growing open-source and community campaigns on Open Collective, week by week: what they raise money for and what they have received, as Open Collective states it.',++ categories: ['crowdfunding'], sources: ['opencollective-accounts'],++ },{key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,about: 'Releases and news from the Node.js project blog, week by week.',@@@ -33,7 +33,7 @@ function index({ latest }) {</li>`;};return String(h`<header class="blog-header"><h1>AI desks</h1>--<p class="lede">Niche blogs written by AI from the data OpenVibe.Sources collects (government news, food recalls, deals, hosting plans, developer releases), and one satire column. Each digest links every item it was written from.</p></header>++<p class="lede">Niche blogs written by AI from the data OpenVibe.Sources collects (government news, food recalls, deals, hosting plans, crowdfunding, developer releases), and one satire column. Each digest links every item it was written from.</p></header><p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE} The people's blogs are elsewhere on <a href="/">OpenVibe.Blog</a>.</p><ul class="desk-list">${catalog.ALL.map(card)}</ul>`);}@@@ -53,6 +53,21 @@ ${raw(ssr.paginationHtml(pager))}${staff ? h`<p class="meta">Staff see hidden posts here too.</p>` : ''}`);}++/**++ * The items under a digest: the ones it cites, numbered as the citations are; the rest of what the AI was given folded++ * away below them (same numbers, so nothing renumbers).++ */++function sourcesList(post) {++ const cited = new Set();++ for (const s of post.body.sections || []) for (const p of (Array.isArray(s.paragraphs) ? s.paragraphs : [{ cites: s.cites || [] }])) for (const n of p.cites || []) cited.add(n);++ const item = (s, i) => h`<li id="src-${i + 1}" value="${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`;++ const all = post.sources.map((s, i) => ({ s, i }));++ const used = all.filter((x) => cited.has(x.i));++ const rest = all.filter((x) => !cited.has(x.i));++ return h`<section class="sources"><h2>The items it cites</h2><ol>${used.map((x) => item(x.s, x.i))}</ol>++${rest.length ? h`<details class="desk-gaps"><summary>${rest.length} more item${rest.length === 1 ? '' : 's'} from the period it was given</summary><ol>${rest.map((x) => item(x.s, x.i))}</ol></details>` : ''}</section>`;++}++/** The article body (also the feeds' content). */function articleBody(desk, post) {if (desk.satire) {@@@ -69,7 +84,7 @@ ${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.m${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${parasOf(s).map((p) => (p.markdown ? h`${md(p.text, 2)}<p>${cites(p.cites)}</p>` : h`<p>${p.text}${cites(p.cites)}</p>`))}</section>`)}</div>${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}--<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);++${sourcesList(post)}`);}function postPage({ desk, post, staff, csrf }) {@@@ -181,6 +181,7 @@ function stub(handler) {assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));assert.ok(page.text.includes('<p>Two agencies announced things. <span class="desk-cites">[<a href="#src-1" aria-label="Item 1">1</a>, <a href="#src-2" aria-label="Item 2">2</a>]</span></p>') && page.text.includes('id="src-3"'));assert.ok(!page.text.includes('Something the AI made up'));++ assert.ok(page.text.includes('<h2>The items it cites</h2><ol><li id="src-1" value="1">'), 'cited items first, numbered as cited');assert.match(page.headers.get('cache-control'), /max-age=60/);const s = await t.get(`/desks/satire/${satire.slug}`);assert.ok(s.text.includes('<strong>Satire.</strong> This is made up'));
@@ -170,6 +170,7 @@ indexed (`noindex`, not in the sitemaps).
@@ -170,6 +170,7 @@ indexed (`noindex`, not in the sitemaps).
| Food safety digest | `fda-food-recalls` | daily, 06:00 UTC (1 item at least) |
| Food safety digest | `fda-food-recalls` | daily, 06:00 UTC (1 item at least) |
| Deals digest | `dealnews-daily` | daily, 07:00 UTC (5 items at least) |
| Deals digest | `dealnews-daily` | daily, 07:00 UTC (5 items at least) |
| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |
| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |
+| Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) |
| Developer digest | `nodejs-blog` | Mondays, for the week before |
| Developer digest | `nodejs-blog` | Mondays, for the week before |
| Is This Real? (satire) | none | daily, 14:00 UTC |
| Is This Real? (satire) | none | daily, 14:00 UTC |
@@ -33,6 +33,11 @@ const DESKS = [
@@ -33,6 +33,11 @@ const DESKS = [
about: 'New and changed VPS and cloud plans in providers\' own price lists (Akamai/Linode, AWS Lightsail, OVHcloud, Vultr) and low-end hosting offers, week by week.',
about: 'New and changed VPS and cloud plans in providers\' own price lists (Akamai/Linode, AWS Lightsail, OVHcloud, Vultr) and low-end hosting offers, week by week.',
categories: ['hosting'], sources: ['linode-types', 'aws-lightsail-bundles', 'ovhcloud-vps', 'vultr-plans', 'lowendbox-posts', 'lowendtalk-offers'],
categories: ['hosting'], sources: ['linode-types', 'aws-lightsail-bundles', 'ovhcloud-vps', 'vultr-plans', 'lowendbox-posts', 'lowendtalk-offers'],
},
},
+ {
+ key: 'crowdfunding', title: 'Crowdfunding digest', every: 'week', hour: 7, min: 3, max: 30,
+ about: 'New and growing open-source and community campaigns on Open Collective, week by week: what they raise money for and what they have received, as Open Collective states it.',
+ categories: ['crowdfunding'], sources: ['opencollective-accounts'],
+ },
{
{
key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,
key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,
about: 'Releases and news from the Node.js project blog, week by week.',
about: 'Releases and news from the Node.js project blog, week by week.',
@@ -33,7 +33,7 @@ function index({ latest }) {
@@ -33,7 +33,7 @@ function index({ latest }) {
</li>`;
</li>`;
};
};
return String(h`<header class="blog-header"><h1>AI desks</h1>
return String(h`<header class="blog-header"><h1>AI desks</h1>
-<p class="lede">Niche blogs written by AI from the data OpenVibe.Sources collects (government news, food recalls, deals, hosting plans, developer releases), and one satire column. Each digest links every item it was written from.</p></header>
+<p class="lede">Niche blogs written by AI from the data OpenVibe.Sources collects (government news, food recalls, deals, hosting plans, crowdfunding, developer releases), and one satire column. Each digest links every item it was written from.</p></header>
<p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE} The people's blogs are elsewhere on <a href="/">OpenVibe.Blog</a>.</p>
<p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE} The people's blogs are elsewhere on <a href="/">OpenVibe.Blog</a>.</p>
<ul class="desk-list">${catalog.ALL.map(card)}</ul>`);
<ul class="desk-list">${catalog.ALL.map(card)}</ul>`);
}
}
@@ -53,6 +53,21 @@ ${raw(ssr.paginationHtml(pager))}
@@ -53,6 +53,21 @@ ${raw(ssr.paginationHtml(pager))}
${staff ? h`<p class="meta">Staff see hidden posts here too.</p>` : ''}`);
${staff ? h`<p class="meta">Staff see hidden posts here too.</p>` : ''}`);
}
}
+/**
+ * The items under a digest: the ones it cites, numbered as the citations are; the rest of what the AI was given folded
+ * away below them (same numbers, so nothing renumbers).
+ */
+function sourcesList(post) {
+ const cited = new Set();
+ for (const s of post.body.sections || []) for (const p of (Array.isArray(s.paragraphs) ? s.paragraphs : [{ cites: s.cites || [] }])) for (const n of p.cites || []) cited.add(n);
+ const item = (s, i) => h`<li id="src-${i + 1}" value="${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`;
+ const all = post.sources.map((s, i) => ({ s, i }));
+ const used = all.filter((x) => cited.has(x.i));
+ const rest = all.filter((x) => !cited.has(x.i));
+ return h`<section class="sources"><h2>The items it cites</h2><ol>${used.map((x) => item(x.s, x.i))}</ol>
+${rest.length ? h`<details class="desk-gaps"><summary>${rest.length} more item${rest.length === 1 ? '' : 's'} from the period it was given</summary><ol>${rest.map((x) => item(x.s, x.i))}</ol></details>` : ''}</section>`;
+}
+
/** The article body (also the feeds' content). */
/** The article body (also the feeds' content). */
function articleBody(desk, post) {
function articleBody(desk, post) {
if (desk.satire) {
if (desk.satire) {
@@ -69,7 +84,7 @@ ${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.m
@@ -69,7 +84,7 @@ ${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.m
${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${parasOf(s).map((p) => (p.markdown ? h`${md(p.text, 2)}<p>${cites(p.cites)}</p>` : h`<p>${p.text}${cites(p.cites)}</p>`))}</section>`)}</div>
<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${parasOf(s).map((p) => (p.markdown ? h`${md(p.text, 2)}<p>${cites(p.cites)}</p>` : h`<p>${p.text}${cites(p.cites)}</p>`))}</section>`)}</div>
${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}
${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}
-<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);
+${sourcesList(post)}`);
}
}
function postPage({ desk, post, staff, csrf }) {
function postPage({ desk, post, staff, csrf }) {
@@ -181,6 +181,7 @@ function stub(handler) {
@@ -181,6 +181,7 @@ function stub(handler) {
assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));
assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));
assert.ok(page.text.includes('<p>Two agencies announced things. <span class="desk-cites">[<a href="#src-1" aria-label="Item 1">1</a>, <a href="#src-2" aria-label="Item 2">2</a>]</span></p>') && page.text.includes('id="src-3"'));
assert.ok(page.text.includes('<p>Two agencies announced things. <span class="desk-cites">[<a href="#src-1" aria-label="Item 1">1</a>, <a href="#src-2" aria-label="Item 2">2</a>]</span></p>') && page.text.includes('id="src-3"'));
assert.ok(!page.text.includes('Something the AI made up'));
assert.ok(!page.text.includes('Something the AI made up'));
+ assert.ok(page.text.includes('<h2>The items it cites</h2><ol><li id="src-1" value="1">'), 'cited items first, numbered as cited');
assert.match(page.headers.get('cache-control'), /max-age=60/);
assert.match(page.headers.get('cache-control'), /max-age=60/);
const s = await t.get(`/desks/satire/${satire.slug}`);
const s = await t.get(`/desks/satire/${satire.slug}`);
assert.ok(s.text.includes('<strong>Satire.</strong> This is made up'));
assert.ok(s.text.includes('<strong>Satire.</strong> This is made up'));