Skip to content

Commit

Permalink
Merge pull request #2 from h-sugawara/feature/dev-app
Browse files Browse the repository at this point in the history
アプリケーションコードと単体テストコードを追加
  • Loading branch information
h-sugawara committed Nov 18, 2023
2 parents 925c243 + 6ceb983 commit 2fbc302
Show file tree
Hide file tree
Showing 14 changed files with 931 additions and 0 deletions.
23 changes: 23 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

const ogs = require('open-graph-scraper');

const getConfig = require('./lib/configure');
const getParameters = require('./lib/parameters');
const generate = require('./lib/generator');

hexo.extend.tag.register(
'link_preview',
(args, content) => {
return generate(ogs, getParameters(args, content, getConfig(hexo.config)))
.then(tag => tag)
.catch(error => {
console.log('generate error:', error);
return '';
});
},
{
async: true,
ends: true,
}
);
54 changes: 54 additions & 0 deletions lib/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

function hasProperty(obj, key) {
return obj && Object.hasOwnProperty.call(obj, key);
}

function stringLength(text) {
if (typeof text !== 'string') {
throw new Error('text is not string type.');
}

const segmentation = new Intl.Segmenter('ja', { granularity: 'grapheme' });
return [...segmentation.segment(text)].length;
}

function stringSlice(text, start, end) {
if (typeof text !== 'string') {
throw new Error('text is not string type.');
}

const strLength = stringLength(text);

let startIndex = typeof start !== 'number' || isNaN(Number(start)) ? 0 : Number(start);
if (startIndex < 0) {
startIndex = Math.max(startIndex + strLength, 0);
}

let endIndex = typeof end !== 'number' || isNaN(Number(end)) ? strLength : Number(end);
if (endIndex >= strLength) {
endIndex = strLength;
} else if (endIndex < 0) {
endIndex = Math.max(endIndex + strLength, 0);
}

let strings = '';

if (startIndex >= strLength || endIndex <= startIndex) {
return strings;
}
const segmentation = new Intl.Segmenter('ja', { granularity: 'grapheme' });
[...segmentation.segment(text)]
.forEach((value, index) => {
if (startIndex <= index && index < endIndex) {
strings += value.segment;
}
});
return strings;
}

module.exports = {
hasProperty,
stringLength,
stringSlice,
};
49 changes: 49 additions & 0 deletions lib/configure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict';

const { hasProperty } = require('./common');

const ANCHOR_LINK_CLASS_NAME = 'link-preview';
const DESCRIPTION_LENGTH = 140;
const DISGUISE_CRAWLER = true;

module.exports = hexoCfg => {
const config = {
class_name: {
anchor_link: ANCHOR_LINK_CLASS_NAME,
},
description_length: DESCRIPTION_LENGTH,
disguise_crawler: DISGUISE_CRAWLER,
};

if (!hasProperty(hexoCfg, 'link_preview')) {
return config;
}

const hexoCfgLinkPreview = hexoCfg.link_preview;

if (hasProperty(hexoCfgLinkPreview, 'class_name')) {
const hexoCfgLinkPreviewClassName = hexoCfgLinkPreview.class_name;

if (typeof hexoCfgLinkPreviewClassName === 'string') {
config.class_name.anchor_link = hexoCfgLinkPreviewClassName || config.class_name.anchor_link;
} else if (typeof hexoCfgLinkPreviewClassName === 'object') {
if (hasProperty(hexoCfgLinkPreviewClassName, 'anchor_link') && typeof hexoCfgLinkPreviewClassName.anchor_link === 'string') {
config.class_name.anchor_link = hexoCfgLinkPreviewClassName.anchor_link || config.class_name.anchor_link;
}

if (hasProperty(hexoCfgLinkPreviewClassName, 'image') && hexoCfgLinkPreviewClassName.image !== '') {
config.class_name.image = hexoCfgLinkPreviewClassName.image;
}
}
}

if (hasProperty(hexoCfgLinkPreview, 'description_length') && typeof hexoCfgLinkPreview.description_length === 'number') {
config.description_length = hexoCfgLinkPreview.description_length || config.description_length;
}

if (hasProperty(hexoCfgLinkPreview, 'disguise_crawler') && typeof hexoCfgLinkPreview.disguise_crawler === 'boolean') {
config.disguise_crawler = hexoCfgLinkPreview.disguise_crawler;
}

return config;
};
30 changes: 30 additions & 0 deletions lib/generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

const { getOgTitle, getOgDescription, getOgImage } = require('./opengraph');
const { newHtmlDivTag, newHtmlAnchorTag, newHtmlImgTag } = require('./htmltag');

module.exports = (scraper, params) => {
return scraper(params.scrape)
.then(data => data.result)
.then(ogp => {
const { valid: isTitleValid, title: escapedTitle } = getOgTitle(ogp);
const { valid: isDescValid, description: escapedDesc } = getOgDescription(ogp, params.generate.descriptionLength);
const { valid: isImageValid, image: imageUrl } = getOgImage(ogp);

if (!isTitleValid || !isDescValid) {
return newHtmlAnchorTag(params.scrape.url, params.generate);
}

const title = newHtmlDivTag('og-title', escapedTitle);
const desc = newHtmlDivTag('og-description', escapedDesc);
const descriptions = newHtmlDivTag('descriptions', title + desc);
const image = isImageValid
? newHtmlDivTag('og-image', newHtmlImgTag(imageUrl, params.generate)) : '';
const content = image + descriptions;

return newHtmlAnchorTag(params.scrape.url, params.generate, content);
})
.catch(error => {
throw error;
});
};
37 changes: 37 additions & 0 deletions lib/htmltag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

const util = require('hexo-util');
const { hasProperty } = require('./common');

function newHtmlDivTag(className, content) {
return util.htmlTag('div', { class: className }, content, false);
}

function newHtmlAnchorTag(url, config, content) {
const tagAttrs = { href: url, target: config.target, rel: config.rel };

if (typeof content === 'string' && content !== '') {
tagAttrs.class = config.className.anchor_link;
return util.htmlTag('a', tagAttrs, content, false);
} else if (hasProperty(config, 'fallbackText') && typeof config.fallbackText === 'string' && config.fallbackText !== '') {
return util.htmlTag('a', tagAttrs, config.fallbackText);
}

throw new Error('failed to generate a new anchor tag.');
}

function newHtmlImgTag(url, config) {
const tagAttrs = { src: url };

if (hasProperty(config.className, 'image') && typeof config.className.image === 'string' && config.className.image !== '') {
tagAttrs.class = config.className.image;
}

return util.htmlTag('img', tagAttrs, '');
}

module.exports = {
newHtmlDivTag,
newHtmlAnchorTag,
newHtmlImgTag,
};
52 changes: 52 additions & 0 deletions lib/opengraph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

const util = require('hexo-util');
const { hasProperty, stringLength, stringSlice } = require('./common');

function getOgTitle(ogp) {
const result = { valid: false, title: '' };

if (!hasProperty(ogp, 'ogTitle')) {
return result;
}

const escapedTitle = util.escapeHTML(ogp.ogTitle);
if (typeof escapedTitle === 'string' && escapedTitle !== '') {
result.valid = true;
result.title = escapedTitle;
}
return result;
}

function getOgDescription(ogp, maxLength) {
const result = { valid: false, description: '' };

if (!hasProperty(ogp, 'ogDescription')) {
return result;
}

const escapedDescription = util.escapeHTML(ogp.ogDescription);
const descriptionText = maxLength && stringLength(escapedDescription) > maxLength
? stringSlice(escapedDescription, 0, maxLength) + '...' : escapedDescription;
if (typeof descriptionText === 'string' && descriptionText !== '') {
result.valid = true;
result.description = descriptionText;
}
return result;
}

function getOgImage(ogp, selectIndex = 0) {
if (!hasProperty(ogp, 'ogImage') || ogp.ogImage.length === 0) {
return { valid: false, image: '' };
}

const index = selectIndex >= ogp.ogImage.length ? ogp.ogImage.length - 1 : 0;

return { valid: true, image: ogp.ogImage[index].url };
}

module.exports = {
getOgTitle,
getOgDescription,
getOgImage,
};
49 changes: 49 additions & 0 deletions lib/parameters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict';

const urlRegex = /^(http|https):\/\//g;
const targetKeywords = ['_self', '_blank', '_parent', '_top'];
const relKeywords = ['external', 'nofollow', 'noopener', 'noreferrer', 'opener'];

const CRAWLER_USER_AGENT = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/112.0.0.0 Safari/537.36';

function parseArgs(args) {
const urls = args.filter(arg => arg.search(urlRegex) === 0);
if (urls.length < 1) {
throw new Error('Scraping target url is not contains.');
}

const targets = args.filter(arg => targetKeywords.includes(arg));
const relationships = args.filter(arg => relKeywords.includes(arg));

return {
url: urls[0],
target: targets[0] || '_blank',
rel: relationships[0] || 'nofollow',
};
}

function getFetchOptions(isCrawler) {
const fetchOptions = {};
const headers = {};

if (typeof isCrawler === 'boolean' && isCrawler) {
headers['user-agent'] = CRAWLER_USER_AGENT;
}

if (Object.keys(headers).length !== 0) {
fetchOptions.headers = headers;
}

return fetchOptions;
}

module.exports = (args, content, config) => {
const { url, target, rel } = parseArgs(args);
const { class_name: className, description_length: descriptionLength, disguise_crawler: isCrawler } = config;
const fetchOptions = getFetchOptions(isCrawler);

return {
scrape: { url, fetchOptions },
generate: { target, rel, descriptionLength, className, fallbackText: content },
};
};
53 changes: 53 additions & 0 deletions test/common.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';

const { hasProperty, stringLength, stringSlice } = require('../lib/common');

describe('common', () => {
it('Has a property at object', () => {
const obj = { 'test_key': 'test_value' };

expect(hasProperty(obj, 'test_key')).toBeTruthy();
});

it('Has not a property at object', () => {
const obj = { 'test_key': 'test_value' };

expect(hasProperty(obj, 'not_test_key')).toBeFalsy();
});

it('Calculate a length of string', () => {
expect(stringLength('aBあア亞  19%+👨🏻‍💻🇯🇵🍎')).toEqual(14);
});

it('Cannot calculate a length of except for string', () => {
expect(() => stringLength(0)).toThrow(new Error('text is not string type.'));
});

it('Slice a string', () => {
expect(stringSlice('aBあア亞  19%+👨🏻‍💻🇯🇵🍎', 0, 3)).toEqual('aBあ');
});

it('Cannot slice a value except for string', () => {
expect(() => stringSlice(0)).toThrow(new Error('text is not string type.'));
});

it('Slice a string (start is not number type)', () => {
expect(stringSlice('aBあア亞  19%+👨🏻‍💻🇯🇵🍎', 'hoge', 5)).toEqual('aBあア亞');
});

it('Slice a string (start is smaller than zero)', () => {
expect(stringSlice('aBあア亞  19%+👨🏻‍💻🇯🇵🍎', -5, 13)).toEqual('%+👨🏻‍💻🇯🇵');
});

it('Slice a string (end calculate automatically)', () => {
expect(stringSlice('aBあア亞  19%+👨🏻‍💻🇯🇵🍎', 7)).toEqual('19%+👨🏻‍💻🇯🇵🍎');
});

it('Slice a string (end is smaller than zero)', () => {
expect(stringSlice('aBあア亞  19%+👨🏻‍💻🇯🇵🍎', 7, -4)).toEqual('19%');
});

it('Slice a string (end is smaller than start)', () => {
expect(stringSlice('aBあア亞  19%+👨🏻‍💻🇯🇵🍎', -1, -2)).toEqual('');
});
});

0 comments on commit 2fbc302

Please sign in to comment.