feat(route)(picuki/profile): migrate to v2, fetch stories (#9216)

* refactor(route)(picuki/profile): migrate to v2

other changes:
fix linebreak
fix header
add location
`displayVideo` param defaults to enable

Signed-off-by: Rongrong <15956627+Rongronggg9@users.noreply.github.com>

* refactor(route)(picuki/profile): use template

Signed-off-by: Rongrong <15956627+Rongronggg9@users.noreply.github.com>

* feat(route)(picuki/profile): fetch stories

Signed-off-by: Rongrong <15956627+Rongronggg9@users.noreply.github.com>
This commit is contained in:
Rongrong 2022-03-02 22:26:21 +08:00 committed by GitHub
parent 271efc81ba
commit 44f1ea3718
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 225 additions and 107 deletions

View File

@ -924,7 +924,6 @@
},
],
},
'picuki.com': { _name: 'Picuki', www: [{ title: '用户', docs: 'https://docs.rsshub.app/social-media.html#picuki-yong-hu', source: '/profile/:id', target: '/picuki/profile/:id' }] },
'jjmhw.cc': { _name: '漫小肆', www: [{ title: '漫画更新', docs: 'https://docs.rsshub.app/anime.html#man-xiao-si', source: '/book/:id', target: '/manxiaosi/book/:id' }] },
'wenxuecity.com': {
_name: '文学城',

View File

@ -187,7 +187,22 @@ These feed do not include boosts (a.k.a. reblogs). RSSHub provides a feed for us
### User Profile
<RouteEn author="hoilc" example="/picuki/profile/stefaniejoosten" path="/picuki/profile/:id/:displayVideo?" :paramsDesc="['Instagram id','Default to disable the embedded video, set to any value to enable embedding']" />
<RouteEn author="hoilc Rongronggg9" example="/picuki/profile/stefaniejoosten" path="/picuki/profile/:id/:functionalFlag?" :paramsDesc="['Instagram id','functional flag, see the table below']" radar="1" rssbud="1">
| functionalFlag | Video embedding | Fetching Instagram Stories |
|----------------|-----------------------------------------|----------------------------|
| 0 | off, only show video poster as an image | off |
| 1 (default) | on | off |
| 10 | on | on |
::: warning
Instagram Stories do not have a reliable guid. It is possible that your RSS reader show the same story more than once.
Though, every Story expires after 24 hours, so it may be not so serious.
:::
</RouteEn>
## pixiv

View File

@ -519,7 +519,22 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性
### 用户
<Route author="hoilc" example="/picuki/profile/stefaniejoosten" path="/picuki/profile/:id/:displayVideo?" :paramsDesc="['Instagram 用户 id','是否显示视频,任意值为是,留空为否']" radar="1" rssbud="1"/>
<Route author="hoilc Rongronggg9" example="/picuki/profile/stefaniejoosten" path="/picuki/profile/:id/:functionalFlag?" :paramsDesc="['Instagram 用户 id','功能标记,见下表']" radar="1" rssbud="1">
| functionalFlag | 嵌入视频 | 获取 Instagram Stories |
|----------------|--------------|----------------------|
| 0 | 关,只用图片显示视频封面 | 关 |
| 1 (默认) | 开 | 关 |
| 10 | 开 | 开 |
::: warning 注意
Instagram Stories 没有可靠的 guid你的 RSS 阅读器可能将同一条 Story 显示多于一次。
尽管如此,每个 Story 都会在 24 小时后过期,所以问题也许没那么严重。
:::
</Route>
## pixiv

View File

@ -2018,17 +2018,6 @@ module.exports = {
},
],
},
'picuki.com': {
_name: 'Picuki',
www: [
{
title: '用户',
docs: 'https://docs.rsshub.app/social-media.html#picuki-yong-hu',
source: '/profile/:id',
target: '/picuki/profile/:id',
},
],
},
'jjmhw.cc': {
_name: '漫小肆',
www: [

View File

@ -3111,9 +3111,6 @@ router.get('/behance/:user/:type?', lazyloadRouteHandler('./routes/behance/index
// 北京物资学院
router.get('/bwu/news', lazyloadRouteHandler('./routes/universities/bwu/news'));
// Picuki
router.get('/picuki/profile/:id/:displayVideo?', lazyloadRouteHandler('./routes/picuki/profile'));
// 新榜
router.get('/newrank/wechat/:wxid', lazyloadRouteHandler('./routes/newrank/wechat'));
router.get('/newrank/douyin/:dyid', lazyloadRouteHandler('./routes/newrank/douyin'));

View File

@ -1,90 +0,0 @@
const got = require('@/utils/got');
const cheerio = require('cheerio');
const chrono = require('chrono-node');
module.exports = async (ctx) => {
const id = ctx.params.id;
const displayVideo = !!ctx.params.displayVideo;
const url = `https://www.picuki.com/profile/${id}`;
const response = await got.get(url);
const $ = cheerio.load(response.data);
const profile_name = $('.profile-name-bottom').text();
const profile_img = $('.profile-avatar > img').attr('src');
const profile_description = $('.profile-description').text();
const list = $('ul.box-photos [data-s="media"]').get();
async function getMedia(url) {
const response = await got.get(url);
const $ = cheerio.load(response.data);
// Instagram 允许最多 10 条图片/视频任意混合于一条 postpicuki 在所有情况下都会将它(们)置于 .single-photo 内
const media_items = $('.single-photo img,video').get();
let html = '';
media_items.forEach((item) => {
item = $(item);
html += item.toString(); // 可能是视频,也可能是图片,格式都比较友好,这里直接添加
});
return html;
}
function deVideo(media) {
const $ = cheerio.load(media);
const media_list = $('img,video').get();
let media_deVideo = '';
media_list.forEach((medium) => {
medium = $(medium);
const poster = medium.attr('poster');
// 如果有 poster 属性,表明它是视频,将它替换为它的 poster如果不是就原样返回
media_deVideo += poster ? `<img src='${poster}' alt='video poster'>` : medium.toString();
});
return media_deVideo;
}
const items = await Promise.all(
list.map(async (post) => {
post = $(post);
const post_link = post.find('.photo > a').attr('href');
const cache = await ctx.cache.get(post_link);
let cache_d;
if (cache) {
cache_d = JSON.parse(cache);
} else {
const post_description = post.find('.photo-description').text().trim();
const post_time = post.find('.time');
const media_displayVideo = await getMedia(post_link);
const media = deVideo(media_displayVideo);
cache_d = {
title: post_description || 'Untitled',
author: `@${id}`,
media_prefix: media,
media_displayVideo_prefix: media_displayVideo,
description_body: post_description.length > 100 ? `<p>${post_description}</p>` : '',
link: post_link,
pubDate: post_time ? chrono.parseDate(post_time.text()) : new Date(),
};
ctx.cache.set(post_link, JSON.stringify(cache_d));
}
cache_d.description = (displayVideo ? cache_d.media_displayVideo_prefix : cache_d.media_prefix) + cache_d.description_body;
delete cache_d.media_prefix;
delete cache_d.media_displayVideo_prefix;
delete cache_d.description_body;
return cache_d;
})
);
ctx.state.data = {
title: `${profile_name} (@${id}) - Picuki`,
link: url,
image: profile_img,
description: profile_description,
item: items,
};
};

View File

@ -0,0 +1,3 @@
module.exports = {
'/profile/:id/:displayVideo?': ['hoilc', 'Rongronggg9'],
};

167
lib/v2/picuki/profile.js Normal file
View File

@ -0,0 +1,167 @@
const got = require('@/utils/got');
const cheerio = require('cheerio');
const chrono = require('chrono-node');
const { art } = require('@/utils/render');
const path = require('path');
module.exports = async (ctx) => {
const id = ctx.params.id;
const displayVideo = ctx.params.functionalFlag !== '0';
const includeStories = ctx.params.functionalFlag === '10';
const profileUrl = `https://www.picuki.com/profile/${id}`;
const response = await got.get(profileUrl);
const $ = cheerio.load(response.data);
const profileName = $('.profile-name-bottom').text();
const profileImg = $('.profile-avatar > img').attr('src');
const profileDescription = $('.profile-description').text();
const list = $('ul.box-photos [data-s="media"]').get();
async function getMedia(url) {
const getPost = async () => {
const response = await got.get(url, {
headers: {
Referer: profileUrl,
},
});
return response.data;
};
let data = await ctx.cache.tryGet(url, getPost);
if (Object.prototype.toString.call(data) === '[object Object]') {
// oops, it's a json, maybe it's an old cache from the old version of this route!
data = await getPost();
// re-cache it
await ctx.cache.set(url, data);
}
const $ = cheerio.load(data);
// Instagram 允许最多 10 条图片/视频任意混合于一条 postpicuki 在所有情况下都会将它(们)置于 .single-photo 内
let html = '';
$('.single-photo img,video').each((_, item) => {
html += $(item).toString(); // 可能是视频,也可能是图片,格式都比较友好,这里直接添加
});
return html;
}
function deVideo(media) {
const $ = cheerio.load(media);
let media_deVideo = '';
$('img,video').each((_, medium) => {
const tag = medium.name;
medium = $(medium);
const poster = medium.attr('poster');
// 如果有 poster 属性,表明它是视频,将它替换为它的 poster如果不是就原样返回
media_deVideo += poster ? `<img src='${poster}' alt='video poster'>` : tag === 'img' ? medium.toString() : '';
});
return media_deVideo;
}
let items = await Promise.all(
list.map(async (post) => {
post = $(post);
const postLink = post.find('.photo > a').attr('href');
const postTime = post.find('.time');
const pubDate = postTime ? chrono.parseDate(postTime.text()) : new Date();
const media_displayVideo = await getMedia(postLink);
const postText = post
.find('.photo-description')
.text()
.trim()
.replaceAll(/[^\S\n]+/g, ' ')
.replaceAll(/((?<=\n|^)[^\S\n])|([^\S\n](?=\n|$))/g, '');
const title = postText.replaceAll('\n', ' ') || 'Untitled';
const description = art(path.join(__dirname, 'templates/post.art'), {
media: displayVideo ? media_displayVideo : deVideo(media_displayVideo),
desc: postText,
locationLink: post.find('.photo-location .icon-globe-alt a'),
});
return {
title,
author: `@${id}`,
description,
link: postLink,
pubDate,
};
})
);
const getStories = async (queryId) => {
const apiUrl = `https://www.picuki.com/app/controllers/ajax.php`;
const key = `${apiUrl}?query=${queryId}&type=story`;
const data = await ctx.cache.tryGet(key, async () => {
const response = await got.post(apiUrl, {
form: {
query: queryId,
type: 'story',
},
headers: {
Referer: profileUrl,
Origin: 'https://www.picuki.com',
'X-Requested-With': 'XMLHttpRequest',
},
});
return response.data;
});
if (data && data.stories_container) {
const $ = cheerio.load(data.stories_container);
const storyItems = $('.item')
.get()
.map((item) => {
const $item = $(item);
const postTime = $item.find('.stories_count');
const pubDate = postTime.length ? chrono.parseDate(postTime.text()) : new Date();
const postBox = $item.find('.launchLightbox');
const poster = postBox.attr('data-video-poster');
const href = postBox.attr('href');
const type = postBox.attr('data-post-type'); // video / image
const storiesBackground = $item.find('.stories_background');
const storiesBackgroundUrl = storiesBackground && storiesBackground.css('background-image').match(/url\('?(.*)'?\)/);
const storiesBackgroundUrlSrc = storiesBackgroundUrl && storiesBackgroundUrl[1];
let description;
if (type === 'video') {
description = `<video controls src="${href}" poster="${poster || storiesBackgroundUrlSrc || ''}">`;
} else if (type === 'image') {
description = `<img src="${href || poster || storiesBackgroundUrlSrc || ''}" alt="Instagram Story">`;
}
return {
title: 'Instagram Story',
author: `@${id}`,
description,
link: href,
pubDate,
};
});
if (storyItems.length) {
items = items.concat(storyItems);
}
}
};
if (includeStories) {
let profileQueryId;
$('script:not([src])').each((_, script) => {
const queryMatch = $(script)
.html()
.match(/let query ?= ?['"](\d+)['"]/);
if (queryMatch) {
profileQueryId = queryMatch[1];
}
});
if (profileQueryId) {
await getStories(profileQueryId);
}
}
ctx.state.data = {
title: `${profileName} (@${id}) - Picuki`,
link: profileUrl,
image: profileImg,
description: profileDescription,
item: items,
};
};

13
lib/v2/picuki/radar.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
'picuki.com': {
_name: 'Picuki',
www: [
{
title: '用户',
docs: 'https://docs.rsshub.app/social-media.html#picuki-yong-hu',
source: '/profile/:id',
target: '/picuki/profile/:id',
},
],
},
};

3
lib/v2/picuki/router.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = function (router) {
router.get('/profile/:id/:functionalFlag?', require('./profile'));
};

View File

@ -0,0 +1,7 @@
{{ if media }}{{@ media.replaceAll('\n', '') }}{{ /if }}
{{ if desc }}<p>{{@ desc.replaceAll('\n', '<br>') }}</p>{{ /if }}
{{ if locationLink.length && locationLink.attr('href') }}
<p>📍 <small><a href="{{ locationLink.attr('href') }}">{{ locationLink.text() }}</a></small></p>
{{ else if locationLink.length }}
<p>📍 <small>{{ locationLink.text() }}</small></p>
{{ /if }}