Back to APIs list

Twitter API examples & templates

Use these vals as a playground to view and fork Twitter API examples and templates on Val Town. Run any example below or find templates that can be used as a pre-built solution.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/** @jsxImportSource https://esm.sh/react */
import { renderToString } from "npm:react-dom/server";
export default async function(req: Request) {
return new Response(
renderToString(
<html>
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Joe Schmo</title>
</head>
<body
style={{ padding: "30px", width: "300px", margin: "0 auto", fontFamily: "sans-serif", textAlign: "center" }}
>
<h1>Joe Schmo</h1>
<p>Just an average Joe</p>
<div style={{ display: "flex", flexDirection: "column", gap: "15px" }}>
<a href="https://www.instagram.com/joeschmo" style={itemStyle}>Instagram</a>
<a href="https://github.com/joeschmo" style={itemStyle}>Github</a>
<a href="https://www.linkedin.com/in/joeschmo" style={itemStyle}>LinkedIn</a>
<a href="https://twitter.com/joeschmo" style={itemStyle}>Twitter</a>
<a href="https://www.youtube.com/joeschmo" style={itemStyle}>YouTube</a>
</div>
</body>
</html>,
),
{
headers: {
"Content-Type": "text/html",
},
},
);
}
const itemStyle = {
padding: "10px",
color: "white",
backgroundColor: "#ff748d",
borderRadius: "20px",
textDecoration: "none",
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
};

<3 Val Town

Val Town is my new favourite thing. Never heard of it ?

Well, according to it's homepage, Val Town is a social website to write and deploy TypeScript. It's often introduced as zappier for developers, or twitter for code.

The idea is simple: you write down a javascript snippet (named vals) in your browser, and it's instantly executed on a server. You can use it to:

  • execute a function on a cron schedule
  • host a small websites (this article hosted on Val Town)
  • send yourself emails
  • ...

But there is more to Val Town than this. If you take a look at the trending vals, you will quickly notice a pattern: most of the vals are about Val Town itself. People are using Val Town to extend Val Town, and it's fascinating to see what they come up with.

I've built a few of these extensions myself, and this article is about one of them.

Fixing the Val Town Search

Val.town is built around the http import feature of Deno. Each val is a standalone module, that you can import in other vals. It works both for your own vals, and for the vals of other users.

All of this is great, but there is one big issue: the search feature is terrible. It only works for exact text matches, and there is no way to set any filters based on username, creation_date, or anything else. This makes it really hard to find a val you are looking for, even if you are the one who wrote it.

In any other platform, I would have just given up and moved on. But Val Town is different. I was confident that I could address this issue in userspace, without having to wait for the platform to implement it.

Val Town allows you to run a val on a cron schedule, so I wrote a val that would fetch all the vals from the API, and store them as a sqlite table (did I mention that every user get it's own sqlite database ?).

Create valconst createQuery = `CREATE TABLE IF NOT EXISTS vals ( ... );`; // run every hour export default function(interval: Interval) { // create the val table await options.sqlite.execute(createQuery); let url = "https://api.val.town/v1/search/vals?query=%20&limit=100"; // fetch all vals, and store them in the sqlite table while (true) { const resp = await fetch(url); if (!resp.ok) { throw new Error(await resp.text()); } const res = await resp.json(); const rows = res.data.map(valToRow); await insertRows(rows, options); if (!res.links.next) { break; } url = res.links.next; } }

Once the val had finished running, I had a table with all the vals from the platform. I could now run queries on this table to find the vals I was looking for.

Create valimport { sqlite } from "https://esm.town/v/std/sqlite" const res = await sqlite.execute(`SELECT * FROM vals WHERE author = 'pomdtr' && code LIKE '%search%'`);

Of course I could have stopped there, but I wanted to go further. I wanted to share this table with other users, so they could run their own queries on it.

Isolating the Vals Table

There was still a challenge to overcome: the table was part of my account database, and I didn't want to give everyone access to it (there are some sensitive tables in there).

One way to solve this issue would be to publish a stripped-down api that only allows a few predefined queries. But that would be boring, and I wanted to give users the full power of SQL.

So I decided to isolate the val table in a separate account. There is a neat trick to achieve this on val.town: each val get's it own email address, and email sent to vals can be forwarded to your own email address.

Create valimport { email as sendEmail } from "https://esm.town/v/std/email?v=11"; // triggered each time an email is sent to pomdtr.sqlite_email@valtown.email export default async function(email: Email) { // forward the email to my own email address await sendEmail({ subject: email.subject, html: email.html, text: email.text, }); }

Since val.town account can be created with a val.email address, you can create an infinite number of accounts (and thus sqlite databases) using this trick.

So say hello to the sqlite account, which is a separate account that only contains the vals table.

After creating the account, I just needed to fork the cron val from my main account to get a copy of the vals table in the sqlite account.

Publishing the Table

The val.town stdlib provides a neat rpc function that provides a simple way to expose a function as an API. So I decided to write a simple val that would run a query on the table, and return the result.

Create valimport { rpc } from "https://esm.town/v/std/rpc?v=5"; import { InStatement, sqlite } from "https://esm.town/v/std/sqlite?v=4"; // rpc create an server, exposed on the val http endpoint export default rpc(async (statement: InStatement) => { try { // run the query, then return the result as json return await sqlite.execute(statement); } catch (e) { throw new Response(e.message, { status: 500, }); } });

Everyone can now run queries on the table thanks a publically accessible endpoint (you even have write access to it, but I trust you to not mess with it).

You can test it locally using curl and jq:

echo "SELECT * FROM vals WHERE lower(name) LIKE '%feed%' and lower(name) like '%email%' LIMIT 100" | jq -R '{args: [.]} ' | xargs -0 -I {} curl -X POST "https://sqlite-execute.web.val.run" -H "Content-Type: application/json" -d {} | jq

Of course I don't expect the average val.town user to use shell commands to run queries, so I also built an helper val to interact with the API, allowing users to run queries from their own vals.

Create val// only the import changed from the previous example import { db } from "https://esm.town/v/sqlite/db"; // this query will run on the `sqlite` account const res = await db.execute(`SELECT * FROM vals WHERE author = 'pomdtr' && code LIKE '%search%'`);

I've seen some really cool vals built on top of this API. Someone even wrote down a guide to help users interact with it from the command-line!

I hope that someone will build an search UI to interact with it at some point, but in the meantime, you can use a community-contributed sqlite web interface to run queries on top of the vals table.

Val.town as a code-taking app

As I've tried to show, having both a runtime, an editor and an API on the same platform is quite a magic formula. It's probably why val.town resonates so much with me.

Using CodeSandbox, Stackblitz, Repl.it, Gitpod, Github Codespaces or Gitpod feels pretty much the same, everything still revolves around the same concept of a project/repository. They feel uninspired somehow, trying to replicate the desktop IDE experience in the browser, instead of embracing the new possibilities that the web platform offers.

Val.town breaks this mold. I see it as a code-taking app, a place where I can just dump my ideas without worrying about the usual frictions of writing and deploying code.

1
2
3
4
5
6
7
8
9
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=50";
import { serveReadme } from "https://esm.town/v/pomdtr/serve_readme";
import { extractValInfo } from "https://esm.town/v/pomdtr/extractValInfo";
const val = extractValInfo(import.meta.url);
export default codeOnValTown(serveReadme({ val, title: "<3 Val.town" }));
// #blog

Twitter 𝕏 keyword Alerts

Custom notifications for when you, your company, or anything you care about is mentioned on Twitter.

1. Authentication

You'll need a Twitter Bearer Token. Follow these instructions to get one.

Unfortunately it costs $100 / month to have a Basic Twitter Developer account. If you subscribe to Val Town Pro, I can let you "borrow" my token. Just comment on this val and I'll hook you up.

2. Query

Change the query variable for what you want to get notified for.

You can use Twitter's search operators to customize your query, for some collection of keywords, filtering out others, and much more!

3. Notification

Below I'm sending these mentions to a private channel in our company Discord, but you can customize that to whatever you want, @std/email, Slack, Telegram, whatever.

Runs every 1 hrs
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { discordWebhook } from "https://esm.town/v/stevekrouse/discordWebhook";
import { twitterSearch } from "https://esm.town/v/stevekrouse/twitterSearch";
const query = "\"val.town\" OR \"val town\" -_ValTown_";
export async function twitterAlert({ lastRunAt }: Interval) {
const results = await twitterSearch({
query,
start_time: lastRunAt,
bearerToken: Deno.env.get("twitter"),
});
if (!results.length) return;
// format results
let content = results
.map(({ author_name, author_username, text, id }) => `https://fxtwitter.com/${author_username}/status/${id}`)
.join("\n");
// notify
await discordWebhook({
url: Deno.env.get("mentionsDiscord"),
content,
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON";
// export let comments = ["hello world!"];
// export let twitterJSON = ({ url, bearerToken }) =>
// fetchJSON(
// "https://felt.com/api/v1/maps/SssjclTrScGmH4TfPHzNjD/elements",
// { headers: { authorization: `Bearer ${"felt_pat_ZAqb6bBSE2Na1wDVtZQE17p/sK/+odCnpa1CqLMkcXA"}` } },
// );
// export function handler(request: Request) {
// let elements = fetchJSON(
// "https://felt.com/api/v1/maps/SssjclTrScGmH4TfPHzNjD/elements",
// { headers: { authorization: `Bearer ${"felt_pat_ZAqb6bBSE2Na1wDVtZQE17p/sK/+odCnpa1CqLMkcXA"}` } },
// );
// console.log(elements);
// return Response.json({});
// }
export default async function(req: Request): Promise<Response> {
return fetch(
"https://felt.com/api/v1/maps/SssjclTrScGmH4TfPHzNjD/elements",
// headers: { authorization: `Bearer ${"felt_pat_ZAqb6bBSE2Na1wDVtZQE17p/sK/+odCnpa1CqLMkcXA"}` }
);
}

Create your own Myspace profile, deployed to Val town. https://jdan-myspace.web.val.run

Screenshot 2024-04-27 at 7.20.32 PM.png

Click "..." and select Fork to create your own.

Screenshot 2024-04-27 at 7.18.00 PM.png

From there you can:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { myspaceHtml } from "https://esm.town/v/jdan/myspaceHtml";
import { Hono } from "npm:hono@3";
// TODO: Fetch from key-value
const profile = {
displayName: "Jordan",
seo: {
title: "Jordan Scales | Myspace.com",
},
info: {
imageUrl: "https://jordanscales.com/me.png",
allImagesUrl: "https://instagram.com/jdanscales",
status:
`<span style="color:purple"><em>~*~ do you realize<br>I could have been the one to change your life? - xcx ~*~</em></span>`,
gender: "Male",
age: 31,
location: ["Hoboken, NEW JERSEY", "United States"],
lastLogin: "04/27/2024",
},
contact: {
sendMessage: "mailto:hello@example.com",
forwardToFriend: "https://twitter.com/intent/tweet?url=https%3A%2F%2Fjordanscales.com",
addToFriends: "https://twitter.com/jdan",
addToFavorites: "https://www.val.town/v/jdan/myspace",
},
interests: {
general: "Reading, surfing the web, video games, long walks with friends, bubble tea, programming, chess, art",
music:
"LIGHTS, Daft Punk, Mr. Oizo, The Chemical Brothers, CHVRCHES, Japanese Breakfast, The Prodigy, PVRIS, The Japanese House, Poppy, blink-182, Chrome Sparks, Ashnikko, Rezz, Grimes, Bag Raiders, Kim Petras, Tegan and Sara, Charli XCX, MARINA",
television: " Boardwalk Empire, The Sopranos, The Office, Parks and Recreation, King of Queens, See, Crashing",
books:
"Three Body Problem (Remembrance of Earth's Past trilogy), Alex's Adventures in Numberland, Things to Make and Do in the Fourth Dimension, Ball Lightning",
},
details: {
status: "Single",
hereFor: "Dating, Serious Relationships, Friends, Networking",
hometown: "Middletown, NJ",
sign: "Cancer",
smokeDrink: "No / Sometimes",
occupation: "Webmaster",
},
schools: [
{
details: [
"Stevens Institute of Technology",
"Hoboken, NEW JERSEY",
"Degree: Bachelor's Degree",
"Major: Computer Science",
"Minor: Mathematics, Science and Technology Studies",
],
start: "2010",
end: "2014",
},
{
details: [
"Middletown High School South",
"Middletown, NEW JERSEY",
"President: Computer Club",
],
start: "2006",
end: "2010",
},
],
links: [
{ url: "https://twitter.com/jdan", text: "Twitter" },
{ url: "https://mastodon.xyz/@jordan", text: "Mastodon" },
{ url: "https://github.com/jdan", text: "GitHub" },
{ url: "https://notes.jordanscales.com/", text: "Blog" },
{ url: "https://hash.jordanscales.com/", text: "Hashart" },
],
blog: {
allPostsUrl: "https://notes.jordanscales.com",
posts: [
{
title: "Is this true?",
url: "https://notes.jordanscales.com/is-this-true",
},
{
title: "Operating on Infinite Lists",
url: "https://notes.jordanscales.com/infinite-lists",
},
{
title: "I Peeked Into My Node_Modules Directory And You Won’t Believe What Happened Next",
url: "https://notes.jordanscales.com/node_modules",
},
],
},
blurbs: {
aboutMe:
`My name is Jordan and I'm not sure what to put on my new profile. I'm a software developer based out of Hoboken, New Jersey and I like to
build things that make people smile.<br><br>
I'm currently trying to get better at <a href="https://lichess.org/@/jordanscales">chess</a> and occasionally making
<a href="https://hash.jordanscales.com/">some art</a>.<br><br>
I write the most words on twitter (<a href="https://twitter.com/jdan">@jdan</a>) and <a href="https://notes.jordanscales.com">my blog</a>.<br><br>
`,
whoIdLikeToMeet: "Tom! Thank you for making the Internet truly good for a while. but most of all, Samy is my hero",
},
top8: {

Create your own Myspace profile, deployed to Val town. https://jdan-myspace.web.val.run

Screenshot 2024-04-27 at 7.20.32 PM.png

Click "..." and select Fork to create your own.

Screenshot 2024-04-27 at 7.18.00 PM.png

From there you can:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { myspaceHtml } from "https://esm.town/v/jdan/myspaceHtml";
import { Hono } from "npm:hono@3";
// TODO: Fetch from key-value
const profile = {
displayName: "Jordan",
seo: {
title: "Jordan Scales | Myspace.com",
},
info: {
imageUrl: "https://jordanscales.com/me.png",
allImagesUrl: "https://instagram.com/jdanscales",
status:
`<span style="color:purple"><em>~*~ do you realize<br>I could have been the one to change your life? - xcx ~*~</em></span>`,
gender: "Male",
age: 31,
location: ["Hoboken, NEW JERSEY", "United States"],
lastLogin: "04/27/2024",
},
contact: {
sendMessage: "mailto:hello@example.com",
forwardToFriend: "https://twitter.com/intent/tweet?url=https%3A%2F%2Fjordanscales.com",
addToFriends: "https://twitter.com/jdan",
addToFavorites: "https://www.val.town/v/jdan/myspace",
},
interests: {
general: "Reading, surfing the web, video games, long walks with friends, bubble tea, programming, chess, art",
music:
"LIGHTS, Daft Punk, Mr. Oizo, The Chemical Brothers, CHVRCHES, Japanese Breakfast, The Prodigy, PVRIS, The Japanese House, Poppy, blink-182, Chrome Sparks, Ashnikko, Rezz, Grimes, Bag Raiders, Kim Petras, Tegan and Sara, Charli XCX, MARINA",
television: " Boardwalk Empire, The Sopranos, The Office, Parks and Recreation, King of Queens, See, Crashing",
books:
"Three Body Problem (Remembrance of Earth's Past trilogy), Alex's Adventures in Numberland, Things to Make and Do in the Fourth Dimension, Ball Lightning",
},
details: {
status: "Single",
hereFor: "Dating, Serious Relationships, Friends, Networking",
hometown: "Middletown, NJ",
sign: "Cancer",
smokeDrink: "No / Sometimes",
occupation: "Webmaster",
},
schools: [
{
details: [
"Stevens Institute of Technology",
"Hoboken, NEW JERSEY",
"Degree: Bachelor's Degree",
"Major: Computer Science",
"Minor: Mathematics, Science and Technology Studies",
],
start: "2010",
end: "2014",
},
{
details: [
"Middletown High School South",
"Middletown, NEW JERSEY",
"President: Computer Club",
],
start: "2006",
end: "2010",
},
],
links: [
{ url: "https://twitter.com/jdan", text: "Twitter" },
{ url: "https://mastodon.xyz/@jordan", text: "Mastodon" },
{ url: "https://github.com/jdan", text: "GitHub" },
{ url: "https://notes.jordanscales.com/", text: "Blog" },
{ url: "https://hash.jordanscales.com/", text: "Hashart" },
],
blog: {
allPostsUrl: "https://notes.jordanscales.com",
posts: [
{
title: "Is this true?",
url: "https://notes.jordanscales.com/is-this-true",
},
{
title: "Operating on Infinite Lists",
url: "https://notes.jordanscales.com/infinite-lists",
},
{
title: "I Peeked Into My Node_Modules Directory And You Won’t Believe What Happened Next",
url: "https://notes.jordanscales.com/node_modules",
},
],
},
blurbs: {
aboutMe:
`My name is Jordan and I'm not sure what to put on my new profile. I'm a software developer based out of Hoboken, New Jersey and I like to
build things that make people smile.<br><br>
I'm currently trying to get better at <a href="https://lichess.org/@/jordanscales">chess</a> and occasionally making
<a href="https://hash.jordanscales.com/">some art</a>.<br><br>
I write the most words on twitter (<a href="https://twitter.com/jdan">@jdan</a>) and <a href="https://notes.jordanscales.com">my blog</a>.<br><br>
`,
whoIdLikeToMeet: "Tom! Thank you for making the Internet truly good for a while. but most of all, Samy is my hero",
},
top8: {

Create your own Myspace profile, deployed to Val town. https://jdan-myspace.web.val.run

Screenshot 2024-04-27 at 7.20.32 PM.png

Click "..." and select Fork to create your own.

Screenshot 2024-04-27 at 7.18.00 PM.png

From there you can:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { myspaceHtml } from "https://esm.town/v/jdan/myspaceHtml";
import { Hono } from "npm:hono@3";
// TODO: Fetch from key-value
const profile = {
displayName: "Jordan",
seo: {
title: "Jordan Scales | Myspace.com",
},
info: {
imageUrl: "https://jordanscales.com/me.png",
allImagesUrl: "https://instagram.com/jdanscales",
status:
`<span style="color:purple"><em>~*~ do you realize<br>I could have been the one to change your life? - xcx ~*~</em></span>`,
gender: "Male",
age: 31,
location: ["Hoboken, NEW JERSEY", "United States"],
lastLogin: "04/27/2024",
},
contact: {
sendMessage: "mailto:hello@example.com",
forwardToFriend: "https://twitter.com/intent/tweet?url=https%3A%2F%2Fjordanscales.com",
addToFriends: "https://twitter.com/jdan",
addToFavorites: "https://www.val.town/v/jdan/myspace",
},
interests: {
general: "Reading, surfing the web, video games, long walks with friends, bubble tea, programming, chess, art",
music:
"LIGHTS, Daft Punk, Mr. Oizo, The Chemical Brothers, CHVRCHES, Japanese Breakfast, The Prodigy, PVRIS, The Japanese House, Poppy, blink-182, Chrome Sparks, Ashnikko, Rezz, Grimes, Bag Raiders, Kim Petras, Tegan and Sara, Charli XCX, MARINA",
television: " Boardwalk Empire, The Sopranos, The Office, Parks and Recreation, King of Queens, See, Crashing",
books:
"Three Body Problem (Remembrance of Earth's Past trilogy), Alex's Adventures in Numberland, Things to Make and Do in the Fourth Dimension, Ball Lightning",
},
details: {
status: "Single",
hereFor: "Dating, Serious Relationships, Friends, Networking",
hometown: "Middletown, NJ",
sign: "Cancer",
smokeDrink: "No / Sometimes",
occupation: "Webmaster",
},
schools: [
{
details: [
"Stevens Institute of Technology",
"Hoboken, NEW JERSEY",
"Degree: Bachelor's Degree",
"Major: Computer Science",
"Minor: Mathematics, Science and Technology Studies",
],
start: "2010",
end: "2014",
},
{
details: [
"Middletown High School South",
"Middletown, NEW JERSEY",
"President: Computer Club",
],
start: "2006",
end: "2010",
},
],
links: [
{ url: "https://twitter.com/jdan", text: "Twitter" },
{ url: "https://mastodon.xyz/@jordan", text: "Mastodon" },
{ url: "https://github.com/jdan", text: "GitHub" },
{ url: "https://notes.jordanscales.com/", text: "Blog" },
{ url: "https://hash.jordanscales.com/", text: "Hashart" },
],
blog: {
allPostsUrl: "https://notes.jordanscales.com",
posts: [
{
title: "Is this true?",
url: "https://notes.jordanscales.com/is-this-true",
},
{
title: "Operating on Infinite Lists",
url: "https://notes.jordanscales.com/infinite-lists",
},
{
title: "I Peeked Into My Node_Modules Directory And You Won’t Believe What Happened Next",
url: "https://notes.jordanscales.com/node_modules",
},
],
},
blurbs: {
aboutMe:
`My name is Jordan and I'm not sure what to put on my new profile. I'm a software developer based out of Hoboken, New Jersey and I like to
build things that make people smile.<br><br>
I'm currently trying to get better at <a href="https://lichess.org/@/jordanscales">chess</a> and occasionally making
<a href="https://hash.jordanscales.com/">some art</a>.<br><br>
I write the most words on twitter (<a href="https://twitter.com/jdan">@jdan</a>) and <a href="https://notes.jordanscales.com">my blog</a>.<br><br>
`,
whoIdLikeToMeet: "Tom! Thank you for making the Internet truly good for a while. but most of all, Samy is my hero",
},
top8: {

stevekrouse.com - my personal website

This val hosts my personal website. The view data is stored in Val Town SQLite - @std/sqlite.

It used to live on Github Pages, which is why I proxy over requests to certain blog posts over to the Github Pages site still.

Todos

  • Speed up page load by loading sqlite data later like in @healeycodes/steve_web
  • Store more (legally storable) analytics data, and maybe make a sparkline!
  • Add some sort of way to contact me
  • Move over all my blog posts from Github Pages (maybe into @std/blob as a CMS?)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource https://esm.sh/react */
import { email } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=5";
import { ReloadScriptReactElement } from "https://esm.town/v/stevekrouse/ReloadScript";
import tailwindURL from "https://esm.town/v/stevekrouse/tailwindURL";
import { renderToString } from "npm:react-dom/server";
const linkClass = "text-blue-500 hover:underline";
const Link = (
{ children, href }: {
children?: React.ReactNode;
href: string;
},
) => <a className={linkClass} href={href}>{children}</a>;
const dateClass = "text-xs text-gray-400 font-mono mr-1 hidden sm:inline-block";
async function getHits() {
const [, , { rows: [[allHits]] }, { rows: [[todayHits]] }] = await sqlite.batch([
"CREATE TABLE IF NOT EXISTS stevekrouse_com_hits (timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)",
"INSERT INTO stevekrouse_com_hits DEFAULT VALUES",
"SELECT COUNT(*) FROM stevekrouse_com_hits where timestamp > datetime('now', '-28 day')",
"SELECT COUNT(*) from stevekrouse_com_hits where timestamp > datetime('now', '-1 day')",
]);
if (allHits % 100 === 0) email({ subject: `You got ${todayHits} hits today! (${allHits} total)` });
return { allHits, todayHits };
}
export default async (request: Request) => {
const url = new URL(request.url);
if (url.pathname === "/favicon.ico") return new Response(null, { status: 404 });
if (url.pathname !== "/")
return fetch(
`https://stevekrouse.github.io/${url.pathname}${url.search}`,
request as any as RequestInit,
);
const { allHits, todayHits } = await getHits();
return new Response(
renderToString(
<html>
<head>
<title>Steve Krouse</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<script src={tailwindURL} />
{url.searchParams.get("dev")
? <ReloadScriptReactElement vals={[{ valName: "dot_com", userHandle: "stevekrouse" }]} />
: null}
</head>
<body>
<div className="max-w-3xl p-10 space-y-4 mx-auto">
<h1 className="text-orange-600 text-2xl font-medium">Steve Krouse</h1>
<div>
👋 Hi, I'm Steve. I live in Prospect Heights, Brooklyn.
</div>
<div>
I build <Link href="https://val.town">Val Town</Link>, a social website to code in the cloud.
</div>
<div>
This site was{" "}
<Link href="https://www.val.town/v/stevekrouse/dot_com">built in Val Town</Link>. It was viewed{" "}
{todayHits} times today, and {allHits} times this month.
</div>
<div>
<h2 className="text-xl mb-1">Projects</h2>
<ul>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2022 Jul -</span>
<Link href="https://val.town">Val Town</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2022 Sep -</span>
<Link href="https://dateme.directory">Date Me Directory</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2022 Jan -</span>
<Link href="https://twitter.com/stevekrouse/status/1520162279899078657">Zaplib</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2021 Mar -</span>
<Link href="http://updates.compose.run">Compose</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2017 Jul -</span>
<Link href="https://futureofcoding.org/">Future of Coding</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2016 May -</span>
<Link href="https://github.com/stevekrouse/woofjs">WoofJS</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2015 Sep -</span>
<Link href="http://coding.space">The Coding Space Curriculum</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2015 Jul -</span>
<Link href="http://thecodingspace.com">The Coding Space</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2014 Jan -</span>Software Engineer @{" "}
<Link href="https://looker.com/">Looker</Link>
</li>

Sparse autoencoder feature of the day email

This Val sends a daily notification email at the start of every day with a random high confidence (> 0.8) feature drawn from my sparse autoencoders project that tries to find interpretable directions in the latent space of embedding models.

It sends you an email with a brief description of the feature and a link to view more.

Here's an example email from this Val: Screenshot 2024-04-04 at 15.41.34.png

Every time you run it, you'll get a different feature. By default, this uses the lg-v6 model, which I think is a good one to start with, but this may change in the future as I train better feature dictionaries!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { email } from "https://esm.town/v/std/email?v=12";
const modelName = "lg-v6";
const dictionaryUrl = `https://thesephist--prism-start-app.modal.run/models/${modelName}`;
const confidenceThreshold = 0.8;
function randomlyPickFeature(featuresAboveConfidenceThreshold) {
return featuresAboveConfidenceThreshold[Math.floor(Math.random() * featuresAboveConfidenceThreshold.length)];
}
function getFeatureLink(feature) {
const { index } = feature;
return `https://thesephist--prism-start-app.modal.run/f/${modelName}/${index}?layout=2`;
}
export default async function(interval: Interval) {
const response = await fetch(dictionaryUrl);
const { features } = await response.json();
const featuresAboveConfidenceThreshold = features.filter(
feature => feature.confidence > confidenceThreshold,
);
const randomFeature = randomlyPickFeature(featuresAboveConfidenceThreshold);
const { index, label, attributes, confidence, density } = randomFeature;
const messageSubject = `Feature of the day: ${label}`;
const messageBody = [
`Today's feature is #${index}: ${label} with a confidence score of ${confidence} and a density of ${density}.`,
``,
`${attributes}`,
``,
`Read more: ${getFeatureLink(randomFeature)}`,
].join("\n");
const payload = {
subject: messageSubject,
text: messageBody,
};
console.log(payload);
void email(payload);
}

Sparse autoencoder feature of the day email

This Val sends a daily notification email at the start of every day with a random high confidence (> 0.8) feature drawn from my sparse autoencoders project that tries to find interpretable directions in the latent space of embedding models.

It sends you an email with a brief description of the feature and a link to view more.

Here's an example email from this Val: Screenshot 2024-04-04 at 15.41.34.png

Every time you run it, you'll get a different feature. By default, this uses the lg-v3-x1 model, which I think is a good one to start with, but this may change in the future as I train better feature dictionaries!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { email } from "https://esm.town/v/std/email?v=12";
const modelName = "lg-v3-x1";
const dictionaryUrl = `https://thesephist--prism-start-app.modal.run/models/${modelName}`;
const confidenceThreshold = 0.8;
function randomlyPickFeature(featuresAboveConfidenceThreshold) {
return featuresAboveConfidenceThreshold[Math.floor(Math.random() * featuresAboveConfidenceThreshold.length)];
}
function getFeatureLink(feature) {
const { index } = feature;
return `https://thesephist--prism-start-app.modal.run/f/${modelName}/${index}?layout=2`;
}
export default async function(interval: Interval) {
const response = await fetch(dictionaryUrl);
const { features } = await response.json();
const featuresAboveConfidenceThreshold = features.filter(
feature => feature.confidence > confidenceThreshold,
);
const randomFeature = randomlyPickFeature(featuresAboveConfidenceThreshold);
console.log(randomFeature);
}

Twitter 𝕏 keyword Alerts

Custom notifications for when you, your company, or anything you care about is mentioned on Twitter.

1. Authentication

You'll need a Twitter Bearer Token. Follow these instructions to get one.

Unfortunately it costs $100 / month to have a Basic Twitter Developer account. If you subscribe to Val Town Pro, I can let you "borrow" my token. Just comment on this val and I'll hook you up.

2. Query

Change the query variable for what you want to get notified for.

You can use Twitter's search operators to customize your query, for some collection of keywords, filtering out others, and much more!

3. Notification

Below I'm sending these mentions to a private channel in our company Discord, but you can customize that to whatever you want, @std/email, Slack, Telegram, whatever.

Runs every 1 hrs
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { discordWebhook } from "https://esm.town/v/stevekrouse/discordWebhook";
import { twitterSearch } from "https://esm.town/v/stevekrouse/twitterSearch";
const query = "\"val.town\" OR \"val town\" -_ValTown_";
export async function twitterAlert({ lastRunAt }: Interval) {
const results = await twitterSearch({
query,
start_time: lastRunAt,
bearerToken: Deno.env.get("twitter"),
});
if (!results.length) return;
// format results
let content = results
.map(({ author_name, author_username, text, id }) => `https://fxtwitter.com/${author_username}/status/${id}`)
.join("\n");
// notify
await discordWebhook({
url: Deno.env.get("mentionsDiscord"),
content,
});
}

stevekrouse.com - my personal website

This val hosts my personal website. The view data is stored in Val Town SQLite - @std/sqlite.

It used to live on Github Pages, which is why I proxy over requests to certain blog posts over to the Github Pages site still.

Todos

  • Speed up page load by loading sqlite data later like in @healeycodes/steve_web
  • Store more (legally storable) analytics data, and maybe make a sparkline!
  • Add some sort of way to contact me
  • Move over all my blog posts from Github Pages (maybe into @std/blob as a CMS?)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource https://esm.sh/react */
import { email } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=5";
import { ReloadScriptReactElement } from "https://esm.town/v/stevekrouse/ReloadScript";
import tailwindURL from "https://esm.town/v/stevekrouse/tailwindURL";
import React from "npm:react";
import { renderToString } from "npm:react-dom/server";
const linkClass = "text-blue-500 hover:underline";
const Link = (
{ children, href }: {
children?: React.ReactNode;
href: string;
},
) => <a className={linkClass} href={href}>{children}</a>;
const dateClass = "text-xs text-gray-400 font-mono mr-1 hidden sm:inline-block";
async function getHits() {
const [, , { rows: [[allHits]] }, { rows: [[todayHits]] }] = await sqlite.batch([
"CREATE TABLE IF NOT EXISTS stevekrouse_com_hits (timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)",
"INSERT INTO stevekrouse_com_hits DEFAULT VALUES",
"SELECT COUNT(*) FROM stevekrouse_com_hits where timestamp > datetime('now', '-28 day')",
"SELECT COUNT(*) from stevekrouse_com_hits where timestamp > datetime('now', '-1 day')",
]);
if (allHits % 100 === 0) email({ subject: `You got ${todayHits} hits today! (${allHits} total)` });
return { allHits, todayHits };
}
export default async (request: Request) => {
const url = new URL(request.url);
console.log(url);
if (url.pathname === "/favicon.ico") return new Response(null, { status: 404 });
if (url.pathname !== "/")
return fetch(
`https://stevekrouse.github.io/${url.pathname}${url.search}`,
request as any as RequestInit,
);
const { allHits, todayHits } = await getHits();
return new Response(
renderToString(
<html>
<head>
<title>Steve Krouse</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<script src={tailwindURL} />
{url.searchParams.get("dev")
? <ReloadScriptReactElement vals={[{ valName: "dot_com", userHandle: "stevekrouse" }]} />
: null}
</head>
<body>
<div className="max-w-3xl p-10 space-y-4 mx-auto">
<h1 className="text-orange-600 text-2xl font-medium">Steve Krouse</h1>
<div>
👋 Hi, I'm Steve. I live in Prospect Heights, Brooklyn.
</div>
<div>
I build <Link href="https://val.town">Val Town</Link>, a social website to code in the cloud.
</div>
<div>
This site was{" "}
<Link href="https://www.val.town/v/stevekrouse/dot_com">built in Val Town</Link>. It was viewed{" "}
{todayHits} times today, and {allHits} times this month.
</div>
<div>
<h2 className="text-xl mb-1">Projects</h2>
<ul>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2022 Jul -</span>
<Link href="https://val.town">Val Town</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2022 Sep -</span>
<Link href="https://dateme.directory">Date Me Directory</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2022 Jan -</span>
<Link href="https://twitter.com/stevekrouse/status/1520162279899078657">Zaplib</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2021 Mar -</span>
<Link href="http://updates.compose.run">Compose</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2017 Jul -</span>
<Link href="https://futureofcoding.org/">Future of Coding</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2016 May -</span>
<Link href="https://github.com/stevekrouse/woofjs">WoofJS</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2015 Sep -</span>
<Link href="http://coding.space">The Coding Space Curriculum</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2015 Jul -</span>
<Link href="http://thecodingspace.com">The Coding Space</Link>
</li>
<li className="pb-2 sm:pb-1">
<span className={dateClass}>2014 Jan -</span>Software Engineer @{" "}

Add an email entry option to your static website/blog. Easy peasy. 🚀

newsletter.png

PoV: You just hacked together a portfolio website or launched a blog as a static website. Some people who visit might be interested in hearing more from you. ❤️ But you don't want to get lost building your backend, API, DB or fancy apps like SubstandardStack or MailMachineGun for people to sign up to your newsletter. 😩

All you want is a simple input box on your website - when someone types their email, username or social link in and submits it, you want to be notified.

psst...do you want another one that uses the DB instead of email so you can look up all entries at once? Let me know and I'll get cooking!

Quickstart

Call the val URL with data in the query param userContact . That's it!

// Format
`https://<val_url>?userContact=<mandatory_primary_contact>`

// Examples
`https://dvsj-subscribeToNewsletter.web.val.run?userContact=dav.is@zohomail.in`
`https://dvsj-subscribeToNewsletter.web.val.run?userContact=CatalanCabbage`

Bonus

Have extra data apart from email?

Pass any encoded data in the queryParam userData, will be included in the email. It's optional.

// Format
`https://<val_url>?userContact=<mandatory_primary_contact>&userData=<optional_any_data>`

//Examples
`https://dvsj-subscribeToNewsletter.web.val.run?userContact=dav.is@zohomail.in&userData={"time": "2/2/1969", "twitter": "https://twitter.com/dvsj_in"}`

// Note: All values should be URL encoded. Example:
let userData = {"time": "2/2/1969", "twitter": "https://twitter.com/dvsj_in"}
let encodedUserData = encodeURIComponent(userData) //This should go in the query param

Want bot protection?

Add a simple question to your website, like "okay, so what's one minus one?".
In the val, set isBotProtectionOn = true and botProtectionAnswer="0".
When you call the val, include the encoded user's answer to the bot question as botProtection query param.
Answer will be compared with botProtectionAnswer; if the answer is wrong, the request is rejected.

// Format
`https://<val_url>?userContact=<mandatory_primary_contact>&userData=<optional_any_data>&botProtection=<answer>`

//Examples
`https://dvsj-subscribeToNewsletter.web.val.run?userContact=dav.is@zohomail.in&botProtection=123`

Add it to your website

Want to add it to your site but get a headstart coding it? Use this ChatGPT prompt to get code for your website!

I'm building a simple form submission component. It should a submit button and these 2 input boxes: 
1. "userContact" to get the user's email (mandatory)
2. "userData" to get a custom message from the user (optional)

On clicking the submit button: 
1. Both input values should be encoded using "encodeURIComponent" 
2. A GET URL should be built in this format with query params. Include userData query param only if input is not null or empty.
`https://dvsj-subscribeToNewsletter.web.val.run?userContact=<encodedUserContact>&userData=<encodedUserData>`
3. The GET URL should be called and result printed in the console.

I'm using React, so make it a react component.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { email } from "https://esm.town/v/std/email?v=11";
// You can turn this on if needed. Check the readme!
const isBotProtectionOn = false;
const botProtectionAnswer = "0";
export default async function(req: Request): Promise<Response> {
// Get the data from URL's query params
const url = new URL(req.url);
let botProtectionInput = url.searchParams.get("botProtection");
let userContact = url.searchParams.get("userContact");
let userData = url.searchParams.get("userData");
// Check if query params are valid
const areInputsValid = validateInputs(botProtectionInput, userContact);
if (!areInputsValid.isValid) {
return Response.json({
success: false,
msg: areInputsValid.errorMsg,
});
}
// And send email!
await sendEmail(userContact, userData);
return Response.json({ ok: true });
}
async function sendEmail(userContact, userData) {
userContact = decodeURIComponent(userContact);
// Email body should have userData part only if it's present in query params
let userDataMsg = "";
if (userData != null) {
userData = decodeURIComponent(userData);
userDataMsg = `User data was: ${userData} \n`;
}
// Yay!
const subject = `You've got a new subscriber ${userContact}! 🎉`;
const text = `User with contact ${userContact} has signed up to your newsletter. \n ${userDataMsg} Let's goo! 🥂`;
await email({ subject, text });
}
function validateInputs(botProtectionInput, userContact) {
let isValid = true;
let errorMsg = "";
// Validate bot protection input only if it's turned on
if (isBotProtectionOn) {
botProtectionInput = decodeURIComponent(botProtectionInput);
if (botProtectionInput == null || botProtectionInput != botProtectionAnswer) {
isValid = false;
errorMsg += "botProtection failed, expected *** but answer was " + botProtectionInput + ". Bad bot! ";
}
}
// userContact is mandatory
if (userContact == null || userContact.trim() == "") {
isValid = false;
errorMsg +=
"userContact is missing. It should be a queryParam in the URL, like https://<val_url>?userContact=<user_contact> where user_contact is email, username, social link etc. Eg: https://<val_url>?userContact=dav.is@zohomail.in";
}
return { isValid, errorMsg };
}

image.png

You know how when you paste a URL in Twitter or Slack it shows you a nice preview? This val gives you that data.
Given a URL, this will return metadata about the website like title, description, imageURL, image as base64 etc.

Sample input - paste this in your URL bar

https://dvsj-GetWebsiteMetadata.web.val.run?targetURL=https://dvsj.in
https://dvsj-GetWebsiteMetadata.web.val.run?targetURL=<your-target-url-here>

Sample output:

{
   status: 200,
   url: "https://dvsj.in",
   title: "Dav-is-here ➜",
   description: "Davis' not-so-secret stash",
   imgUrl: "https://www.dvsj.in/cover-picture.png",
   imgData: "data:image/png;base64,qwertyblahblah"
}

FAQ:
Why is imgData sent when imgUrl is already present?
Because you shouldn't hotlink images from 3rd parties. Store the base64 image on your server and use it in your app.
It's unfair to use their server bandwidth and could be a security issue for you if they change the content of the link later.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Although you probably want this, you can take a peek at the implementation at https://www.val.town/v/dvsj/getOpengraphMetadata too.
import getOpengraphMetadata from "https://esm.town/v/dvsj/getOpengraphMetadata";
export default async function(req: Request): Promise<Response> {
// First extract query param from the URL
const url = new URL(req.url);
// People forget capitalization all the time. Let's go easy on them and check a few queryParam keys. :)
const targetUrlKeys = [
"targetURL",
"TargetURL",
"targetUrl",
"TargetUrl",
"Targeturl",
"targeturl",
"GIMME_THE_META_DAMMIT",
];
let targetURL = null;
for (let i = 0; i < targetUrlKeys.length; i++) {
targetURL = url.searchParams.get(targetUrlKeys[i]);
if (targetURL != null) {
break;
}
}
// URL isn't present. Oopsie!
if (targetURL == null || targetURL.trim() == "") {
return Response.json({
"error":
"targetURL is missing in query params. If you want to get the metadata for `https://dvsj.in`, call this function in this format: `https://fn-url?targetURL=https://dvsj.in`",
});
}
// Let's go!
return Response.json(await getOpengraphMetadata(targetURL));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// Might look complicated, don't be scared! Just follow the "//", I'll walk you through it.
import { Buffer } from "node:buffer";
import jsdom from "npm:jsdom";
const JSDOM = jsdom.JSDOM;
export default async function getMetadata(url) {
// Do we have a URL?
if (url == null || url.trim() == "") {
return { "status": 400, "error_msg": "Valid URL is required as input. URL was empty." };
}
let resp = await fetch(url);
let text = await resp.text();
// We parse the page as HTML and then pick the data we want.
// Right now we have the page as a looong string, let's convert it into a HTML document
let frag = new JSDOM(text).window.document;
// Get the data we want and send
let imgUrl = getImageUrl(frag, url);
let imgData = await getImageDataFromUrl(imgUrl);
let title = getTitle(frag);
let description = getDescription(frag);
return { "status": 200, "url": url, title, description, imgUrl, imgData };
}
function getImageUrl(frag, url) {
let imgUrl = "";
let selectors = [
"meta[property=\"og:image:secure_url\"]",
"meta[property=\"og:image:url\"]",
"meta[property=\"og:image\"]",
"meta[name=\"twitter:image:src\"]",
"meta[property=\"twitter:image:src\"]",
"meta[name=\"twitter:image\"]",
"meta[property=\"twitter:image\"]",
"meta[itemprop=\"image\"]",
];
// Get image from the HTML fragment
let element;
for (let i = 0; i < selectors.length; i++) {
element = frag.querySelector(selectors[i]);
if (!imgUrl && element && element.content) {
imgUrl = element.content;
}
}
// Still not present? Try to get the image of the author and use it instead
element = frag.querySelector("img[alt*=\"author\" i]");
if (!imgUrl && element && element.src) {
imgUrl = element.getAttribute("src");
}
// Still not present? Well let's take ANY visible image from the page.
// You leave me no choice, my friend.
element = frag.querySelector("img[src]:not([aria-hidden=\"true\"])");
if (!imgUrl && element && element.src) {
imgUrl = element.getAttribute("src");
}
if (imgUrl !== "") {
// Some img src URLs are relative. In that case, convert to absolute.
// https://stackoverflow.com/a/44547904/12415069
imgUrl = new URL(imgUrl, url).href;
}
return imgUrl;
}
async function getImageDataFromUrl(url) {
const response = await fetch(url);
const base64data = Buffer.from(await response.arrayBuffer()).toString("base64");
return "data:image/png;base64," + base64data;
}
function getTitle(frag) {
let element;
let title = "";
let selectors = [
"meta[property=\"og:title\"]",
"meta[name=\"twitter:title\"]",
"meta[property=\"twitter:title\"]",
];
// Get image from the HTML fragment
for (let i = 0; i < selectors.length; i++) {
element = frag.querySelector(selectors[i]);
if (element && element.content) {
title = element.content;
return title;
}
}
// Not present? Take the text that shows up in the browser tab
element = frag.querySelector("title");
if (element && (element.innerText || element.text || element.textContent)) {
title = element.innerText || element.text || element.textContent;
return title;
}
return title;
}