Add microdata parser

This commit is contained in:
Nicholas Jitkoff 2022-12-22 19:50:09 -08:00
parent b8d5440e9e
commit 59b690be6c
5 changed files with 276 additions and 52 deletions

View file

@ -172,6 +172,17 @@ function parseBittyURL(url) {
return {path, hashTitle, hashData}
}
async function testDecode(rawData) {
console.group("Testing decode")
let t1 = performance.now()
let gz = dataToBase64TD(rawData)
let t2 = performance.now()
console.log("textdecode", gz.length, Math.round((t2 - t1)*1000));
let xz = await dataToBase64FR(rawData);
let t3 = performance.now()
console.log("reader", xz.length, Math.round((t3 - t2)*1000));
}
async function testCompression(rawData) {
console.group("Testing compression")
let t1 = performance.now()
@ -442,7 +453,11 @@ async function hashString(string, base = 36) {
function dataToBase64(data) {
return btoa(String.fromCharCode(...new Uint8Array(data)));
return btoa(String.fromCharCode.apply(null, new Uint8Array(data)));
}
function dataToBase64TD(data) {
return btoa(new TextDecoder('utf8').decode(data));
}
function dataToBase64FR(data) {

View file

@ -1,5 +1,5 @@
body {
font-family: sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
margin:0;
}
@ -214,3 +214,38 @@ body:not(.loading) #loader {
90% {box-shadow:var(--ld18),var(--ld2), var(--ld6),var(--ld10),var(--ld14)}
95% {box-shadow:var(--ld19),var(--ld3), var(--ld7),var(--ld11),var(--ld15)}
}
dialog {
border: 2px solid white;
border-radius: 4px;
box-shadow: 0 0 0 2px black, inset 0 0 0 3px black;
min-width: 320px;
padding: 1.2em;
}
dialog, dialog button {font-size: 18px;font-weight: 600;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";}
dialog::backdrop {
background: repeating-conic-gradient(rgba(128,128,128,.5) 0% 25%, transparent 0% 50%) 50% / 4px 4px;
}
dialog button {
border: 1px solid white;
border-radius: 9px;
box-shadow: inset 0 0 0 2px black;
min-width: 4em;
min-height: 1.8em;
background-color: white;
margin-left:1em;
padding: 0 0.8em;
}
dialog button:last-child {
box-shadow: 0 0 0 4px black, inset 0 0 0 2px black;
}
dialog form {
text-align: right;
margin-top: 1em;
font-size: 18px;
}

View file

@ -61,12 +61,7 @@
document.getElementById("favicon").href = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><text y=".9em">'+ favicon + '</text></svg>'
}
window.el = function (tagName, attrs, ...children) {
let l = document.createElement(tagName);
Object.entries(attrs).forEach(([k,v]) => l[k] = v);
children.forEach((c) => l.appendChild(c));
return l;
}
window.el = bitty.el;
const renderers = {
"application/ld+json": {script:"/render/recipe.html"},
@ -85,8 +80,7 @@
function share(info) { // {title, text, url}
if (!info.url) info = {title:document.title, text:document.title, url:location.href};
console.log("Share", info);
if (navigator.share) {
navigator.share(info)
.then(() => { console.log('Shared!');})
@ -196,11 +190,26 @@
renderContent();
}
}
if (e.data.error) {
showError(e.data.error)
}
if (e.data.setStorage) document.localStorage.setItem(contentHash, e.data.set);
if (e.data.getStorage) document.getElementById("iframe").postMessage(document.localStorage.getItem(contentHash), e.origin)
}, false);
function showError(error) {
console.warn("🛑", error)
let dialog = el("dialog",
el("div", {}, error),
el("form", {method: "dialog"},
el("button", {onclick:history.back.bind(history)}, "Learn More"),
el("button", {onclick:history.back.bind(history)}, "Go Back")));
document.body.appendChild(dialog)
dialog.showModal();
}
async function renderContent() {
showLoader(true)

View file

@ -1,42 +1,199 @@
// let title = params.title
parent.postMessage({title:"Parsing Content..."}, "*");
document.body.appendChild(document.createTextNode("• • •"))
let lca = (a,b) => {
return a.parents().has(b).first();
}
const parser = new DOMParser();
const doc = parser.parseFromString(params.body, "text/html");
console.log("🛠️ Parsing Document ", doc)
let ldjson = doc.querySelector('script[type="application/ld+json"]')
if (ldjson) {
cleanRecipe(ldjson)
} else {
alert("No recipe found")
}
function cleanRecipe(ldjson) {
try {
var json = JSON.parse(ldjson.innerText); //JSON.parse(data);
if (json["@type"] != "Recipe") {
json = (json["@graph"] ?? json).find((item) => item["@type"] == "Recipe")
let findLDJsonRecipe = (document) => {
let lds = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
.map(e => e.innerText)
.sort((a,b) => a.length - b.length)
for (const ld of lds) {
let r = JSON.parse(ld);
if (r["@type"] != "Recipe") {
r = Array.isArray(r) ? r : r["@graph"]
r = r?.find((item)=>item["@type"]=="Recipe")
}
if (!r) continue;
delete json.review;
delete json.video;
let f = new FileReader();
f.onload = function(e) {
parent.postMessage({replaceURL:e.target.result, compressURL:"true"}, "*");
};
f.readAsDataURL(new Blob([JSON.stringify(json)],{type : 'application/ld+json;charset=utf-8'}));
} catch (e) {
console.debug("Data", e, {ldjson});
delete r.review;
delete r.video;
if (!r.url) r.url = location.href;
console.log("Found Recipe:", r)
return r;
}
}
function findMicrodataRecipe(doc) {
return parseMicrodata(doc).find((item)=>item["@type"]=="Recipe");
}
let scrapeRecipe = async document => {
// Get title
let recipe = {};
recipe.name = document.title
// Get opengraph metadata
let title = document.evaluate('//meta[@property="og:title"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue;
if (title) recipe.name = title
let siteName = document.evaluate('//meta[@property="og:site_name"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue;
if (siteName) recipe.publisher = {name:siteName}
recipe.image = document.evaluate('//meta[@property="og:image"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue;
// TODO: get siteLogo
// Get microformat metadata
// let microName = document.evaluate('//*[@itemprop="name"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue;
// if (microName) recipe.name = microName;
// recipe.recipeYield = document.evaluate('//*[@itemprop="recipeYield"]/text()', document, null, XPathResult.STRING_TYPE)?.stringValue;
// recipe.totalTime = document.evaluate('//*[@itemprop="totalTime"]/text()', document, null, XPathResult.STRING_TYPE)?.stringValue;
// let ingredients = []
// var xpath = `//*[@itemprop="recipeIngredient"]`;
// const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
// let node = null;
// while (node = match.iterateNext()) {
// ingredients.push(node.innerText)
// }
// if (ingredients.length) recipe.recipeIngredient = ingredients;
// {
// var xpath = `//*[@itemprop="recipeInstructions"]`;
// const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
// let node = null;
// let instructions = [];
// while (node = match.iterateNext()) {
// console.warn("match", node.innerText)
// instructions.push(node.innerText);
// }
// if (instructions.length == 1) instructions = instructions[0].trim().split("\n").filter((a)=>a.length > 1)
// recipe.recipeInstructions = instructions;
// }
// Scrape Yield
if (!recipe.recipeYield) {
for(let text in ["yield", "makes", "serv"]) {
var xpath = `//div[starts-with(text(), '${text}')]`;
const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
let node = null;
while (node = match.iterateNext()) {
recipe.recipeYield = node.innerText
}
}
}
// Scrape ingredients
if (!recipe.recipeIngredient) {
var xpath = "//div[text()='Ingredients']";
const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
let node = null;
while (node = match.iterateNext()) {
console.warn("match", node.parentNode.innerText.split("\n"))
recipe.recipeIngredient = node.parentNode.innerText.trim().split("\n")
}
}
// Scrape instructions
if (!recipe.recipeInstructions) {
var xpath = "//div[text()='Instructions']";
const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
let node = null;
while (node = match.iterateNext()) {
console.warn("Instructions", node.parentNode.innerText.split("\n"))
recipe.recipeInstructions = node.parentNode.innerText.trim().split("\n").filter((a)=>a.length > 1)
}
}
console.warn("Extracted", JSON.stringify(recipe,undefined,2))
// return undefined
return recipe;
}
let recipe = findLDJsonRecipe(doc) || findMicrodataRecipe(doc) || await scrapeRecipe(doc);
if (recipe) {
let url = doc.evaluate('//meta[@property="og:url"]/@content', doc, null, XPathResult.STRING_TYPE)?.stringValue;
if (url) recipe.mainEntityOfPage = url
let f = new FileReader();
f.onload = function(e) {
parent.postMessage({replaceURL:e.target.result, compressURL:"true"}, "*");
};
f.readAsDataURL(new Blob([JSON.stringify(recipe)],{type : 'application/ld+json;charset=utf-8'}));
} else {
parent.postMessage({title:"No Recipe Found...", error:"No recipe was found on this page."}, "*");
}
function parseMicrodata(doc) {
let parsedElements = []
const microdata = [];
let sanitize = (input) => input?.replace(/\s/gi, ' ').trim();
function addValue(obj, key, value) {
if (Array.isArray(obj[key])) {
obj[key].push(value);
} else if (obj[key]) {
obj[key] = [obj[key], value];
} else {
obj[key] = value;
}
}
function parseItem(elem) {
const data = { }
data["@type"] = elem.getAttribute('itemtype')?.split("/").pop();
if (elem.getAttribute('itemid')) data["@id"] = elem.getAttribute('itemid');
traverseItem(elem, data);
parsedElements.push(elem);
return data;
}
function traverseItem(item, data) {
for (const child of item.children) {
if (!child.hasAttribute('itemprop')) {
traverseItem(child, data);
continue;
}
const itemProps = child.getAttribute('itemprop').split(' ');
if (child.hasAttribute('itemscope')) {
const childData = parseItem(child)
itemProps.forEach(key => addValue(data, key, childData));
} else {
itemProps.forEach(key => {
let value = key === 'url' ? child.href : sanitize(child.content || child.textContent || child.src);
addValue(data, key, value)
});
traverseItem(child, data);
}
}
}
doc.querySelectorAll("[itemscope]").forEach(function(elem, i) {
if (!parsedElements.includes(elem)) {
microdata.push(parseItem(elem));
}
});
console.log("micro", doc, microdata)
return microdata;
}

View file

@ -27,7 +27,7 @@ var isWatch = (window.outerWidth < 220);
if (isWatch) document.documentElement.classList.add("watch")
let ignoredTerms = [
"freshly", "use", "dry", "but", "broken", "pieces", "tbsp", "tsp", "peeled", "then", "can", "oz", "fresh", "out", "not", "sprig", "sprigs", "room", "temperature", "still", "see", "notes", "with", "beat", "together", "crust", "very", "cold", "hot", "top", "warm", "one", "note", "teaspoon", "teaspoons", "tablespoon", "tablespoons", "cup", "cups", "taste", "more", "melted", "into", "wide", "pound", "pounds", "gram", "grams", "you", "ounce", "ounces", "thinly", "sliced",
"extra", "optional", "separate", "freshly", "use", "dry", "but", "broken", "pieces", "tbsp", "tsp", "peeled", "then", "can", "oz", "fresh", "out", "not", "sprig", "sprigs", "room", "temperature", "still", "see", "notes", "with", "beat", "together", "crust", "very", "cold", "hot", "top", "warm", "one", "note", "teaspoon", "teaspoons", "tablespoon", "tablespoons", "cup", "cups", "taste", "more", "melted", "into", "wide", "pound", "pounds", "gram", "grams", "you", "ounce", "ounces", "thinly", "sliced",
"pan", "cube", "cubes", "finely", "ground", "garnish", "about", "cut", "and", "smashed", "each", "the", "medium", "large", "small", "for", "chopped", "minced", "grated", "box", "softened", "directed", "shredded", "cooked", "from", "frozen", "thawed"
]
let emojiMap = {
@ -61,6 +61,8 @@ let emojiMap = {
"carrot": "🥕",
"corn": "🌽",
"spicy": "🌶️",
"chard": "🥬",
"peppers": "🫑",
"bell pepper": "🫑",
"cucumber": "🥒",
"leafy green": "🥬",
@ -424,12 +426,16 @@ function faviconForTitle(title) {
function render() {
try {
let data = JSON.parse(window.params.body);
var json = data; //JSON.parse(data);
if (!json["@type"]?.includes("Recipe")) {
json = (json["@graph"] ?? json).find((item) => item["@type"] == "Recipe")
let r = JSON.parse(window.params.body);
if (r["@type"] != "Recipe") {
r = Array.isArray(r) ? r : r["@graph"]
r = r?.find((item)=>item["@type"]=="Recipe")
}
console.log("🍗 Rendering Recipe:", json);
var json = r || JSON.parse(window.params.body)
console.log("🍗 Rendering Recipe:", json, JSON.parse(window.params.body));
} catch (e) {
document.body.appendChild(m("div", "Error parsing recipe", m("p", e.message), m("p", e.stack), m("pre", window.params.body) ));
@ -445,9 +451,10 @@ function render() {
if (!json) return;
let image = json.image;
if (Array.isArray(image)) image = image.shift();
if (Array.isArray(image)) image = image.pop();
image = image?.url || image;
let instructions = json.recipeInstructions;
let instructions = json.recipeInstructions || [];
console.log(json.name)
let title = clean(json.name);
let description = json.description || json.articleBody;
description = clean(description?.replace(/\\n/g, "<br>"))
@ -460,6 +467,7 @@ function render() {
var ingredientTerms = new Set(
Array.from(ingredients.join("\n").matchAll(/(\p{L}\p{M}?)+/gu)).map(m => m[0].length > 2 ? m[0].toLowerCase(): "")
);
if (typeof instructions === "string") instructions = [instructions]
instructions = flattenInstructions(instructions)
@ -628,7 +636,7 @@ function render() {
m("div.headercolumns",
m("header",
m("a.publisherlink", {href:originalURL, target:"_blank"},
publisherImage ? m("img.publisher", { src: publisherImage }) : hostname,
publisherImage ? m("img.publisher", { src: publisherImage }) : (json.publisher?.name || hostname),
),
m(".headerflex",
m(".headerleft",
@ -659,7 +667,7 @@ function render() {
),
m(".actions.print-hide.watch-hide",
originalURL ? m("a.action", { title:"Open original", href: originalURL, target:"_blank"}, m(".icon.public", {innerHTML:icons.public})) : null,
m("a.action", { title:"Share", onclick: share}, m(".icon.share", {innerHTML:icons.share})),
navigator.share ? m("a.action", { title:"Share", onclick: share}, m(".icon.share", {innerHTML:icons.share})) : undefined,
// m("a.action", { title:"Show steps as list", onclick: () => {reformat = !reformat; render(); return false;}}, m(".icon.checklist")),
m("a.action", { title:"Print", onclick: () => {window.print(); return false;} }, m(".icon.print", {innerHTML:icons.print})),
)