From 68e972464ce44104f2ceaae445448884bfe20bc0 Mon Sep 17 00:00:00 2001 From: Tony Date: Sat, 25 Feb 2023 05:12:06 -0200 Subject: [PATCH] feat(route): instagram from cookie (#11952) * feat(route): instagram 2 * docs: add docs * fix: get tabs * docs: fix tag --- docs/en/install/README.md | 7 +- docs/en/social-media.md | 12 ++-- docs/install/README.md | 7 +- docs/social-media.md | 10 ++- lib/config.js | 1 + lib/v2/instagram/common-utils.js | 59 ++++++++++++++++ lib/v2/instagram/maintainer.js | 1 + lib/v2/instagram/private-api/index.js | 63 ++--------------- lib/v2/instagram/router.js | 1 + lib/v2/instagram/web-api/index.js | 70 +++++++++++++++++++ lib/v2/instagram/web-api/utils.js | 99 +++++++++++++++++++++++++++ 11 files changed, 259 insertions(+), 71 deletions(-) create mode 100644 lib/v2/instagram/common-utils.js create mode 100644 lib/v2/instagram/web-api/index.js create mode 100644 lib/v2/instagram/web-api/utils.js diff --git a/docs/en/install/README.md b/docs/en/install/README.md index 2f58df726..7e6c5192d 100644 --- a/docs/en/install/README.md +++ b/docs/en/install/README.md @@ -677,9 +677,10 @@ See docs of the specified route and `lib/config.js` for detailed information. - Instagram: - - `IG_USERNAME`: Your Instagram username - - `IG_PASSWORD`: Your Instagram password - - `IG_PROXY`: Proxy URL for Instagram + - `IG_USERNAME`: Your Instagram username (Private API only) + - `IG_PASSWORD`: Your Instagram password (Private API only) + - `IG_PROXY`: Proxy URL for Instagram (Private API only, optional) + - `IG_COOKIE`: Your Instagram cookie (Cookie only) Warning: Two Factor Authentication is **not** supported. diff --git a/docs/en/social-media.md b/docs/en/social-media.md index 33d0ebf8f..c7f80753e 100644 --- a/docs/en/social-media.md +++ b/docs/en/social-media.md @@ -148,15 +148,15 @@ Type ::: warning -Due to Instagram API restrictions, you have to setup your credentials on the server. See deployment guide for more. +Due to Instagram Private API restrictions, you have to setup your credentials on the server. 2FA is not supported. See [deployment guide](https://docs.rsshub.app/en/install/) for more. -If you don't want to setup credentials, use Picuki. +If you don't want to setup credentials, you can use [Picuki](#picuki). ::: -### User Profile / Hashtag +### User Profile / Hashtag - Private API - + | User timeline | Hashtag | | ---------- | ---- | @@ -168,6 +168,10 @@ It's highly recommended to deploy with Redis cache enabled. +### User Profile / Hashtag - Cookie + + + ## Lofter ### User diff --git a/docs/install/README.md b/docs/install/README.md index ced5ffe50..f8c475196 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -712,9 +712,10 @@ RSSHub 支持使用访问密钥 / 码,白名单和黑名单三种方式进行 - Instagram: - - `IG_USERNAME`: Instagram 用户名。 - - `IG_PASSWORD`: Instagram 密码。 - - `IG_PROXY`: Instagram 代理 URL。 + - `IG_USERNAME`: Instagram 用户名(仅 Private API) + - `IG_PASSWORD`: Instagram 密码(仅 Private API) + - `IG_PROXY`: Instagram 代理 URL(仅 Private API,可选) + - `IG_COOKIE`: Instagram 登录后的 Cookie(仅 Cookie) 注意,暂**不支持**两步验证。 diff --git a/docs/social-media.md b/docs/social-media.md index 864003685..06ea9e7cd 100644 --- a/docs/social-media.md +++ b/docs/social-media.md @@ -488,13 +488,13 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性 ::: warning 注意 -由于 Instagram API 限制,必须在服务器上设置你的用户名和密码。暂不支持两步验证。步骤见部署指南。 +由于 Instagram Private API 限制,必须在服务器上设置你的用户名和密码。暂不支持两步验证。步骤见[部署指南](https://docs.rsshub.app/install/)。 -如需无登录的 feed,请用 Picuki。 +如需无登录的 feed,请用 [Picuki](#picuki)。 ::: -### 用户 / 标签 +### 用户 / 标签 - Private API @@ -508,6 +508,10 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性 +### 用户 / 标签 - Cookie + + + ## Keep ### 运动日记 diff --git a/lib/config.js b/lib/config.js index 124ef72a0..beed36b5e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -185,6 +185,7 @@ const calculateValue = () => { username: envs.IG_USERNAME, password: envs.IG_PASSWORD, proxy: envs.IG_PROXY, + cookie: envs.IG_COOKIE, }, iwara: { cookie: envs.IWARA_COOKIE, diff --git a/lib/v2/instagram/common-utils.js b/lib/v2/instagram/common-utils.js new file mode 100644 index 000000000..8ab6c32e9 --- /dev/null +++ b/lib/v2/instagram/common-utils.js @@ -0,0 +1,59 @@ +const { parseDate } = require('@/utils/parse-date'); +const { art } = require('@/utils/render'); +const path = require('path'); + +const renderItems = (items) => + items.map((item) => { + const { product_type } = item; // carousel_container, feed, clips, igtv + // Content + const summary = item.caption?.text ?? ''; + + let description = ''; + switch (product_type) { + case 'carousel_container': { + const images = item.carousel_media.map((i) => i.image_versions2.candidates[0]); + description = art(path.join(__dirname, 'templates/images.art'), { + summary, + images, + }); + break; + } + case 'clips': + case 'igtv': + description = art(path.join(__dirname, 'templates/video.art'), { + summary, + image: item.image_versions2.candidates[0].url, + video: item.video_versions[0], + }); + break; + case 'feed': { + const images = [item.image_versions2.candidates[0]]; + description = art(path.join(__dirname, 'templates/images.art'), { + summary, + images, + }); + break; + } + default: + throw Error(`Instagram: Unhandled feed type: ${product_type}`); + } + + // Metadata + const url = `https://www.instagram.com/p/${item.code}/`; + const pubDate = parseDate(item.taken_at, 'X'); + const title = summary.split('\n')[0]; + + return { + title, + id: item.pk, + pubDate, + author: item.user.username, + link: url, + summary, + description, + }; + }); + +module.exports = { + renderItems, +}; diff --git a/lib/v2/instagram/maintainer.js b/lib/v2/instagram/maintainer.js index 9e31b9d53..1d19aac18 100644 --- a/lib/v2/instagram/maintainer.js +++ b/lib/v2/instagram/maintainer.js @@ -1,3 +1,4 @@ module.exports = { '/:category/:key': ['oppilate', 'DIYgod'], + '/2/:category/:key': ['TonyRL'], }; diff --git a/lib/v2/instagram/private-api/index.js b/lib/v2/instagram/private-api/index.js index ab2b7a43f..bd188d57f 100644 --- a/lib/v2/instagram/private-api/index.js +++ b/lib/v2/instagram/private-api/index.js @@ -1,9 +1,7 @@ const { ig, login } = require('./utils'); const logger = require('@/utils/logger'); const config = require('@/config').value; -const { parseDate } = require('@/utils/parse-date'); -const { art } = require('@/utils/render'); -const path = require('path'); +const { renderItems } = require('../common-utils'); // loadContent pulls the desired user/tag/etc async function loadContent(category, nameOrId, tryGet) { @@ -24,7 +22,8 @@ async function loadContent(category, nameOrId, tryGet) { } feedDescription = userInfo.biography; - feedLogo = userInfo.hd_profile_pic_url_info?.url ?? userInfo.profile_pic_url; + // exists in web api ?? exist in private api ?? exist in both + feedLogo = userInfo.profile_pic_url_hd ?? userInfo.hd_profile_pic_url_info?.url ?? userInfo.profile_pic_url; const fullName = userInfo.full_name; feedTitle = `${fullName} (@${username}) - Instagram`; feedLink = `https://www.instagram.com/${username}`; @@ -41,9 +40,8 @@ async function loadContent(category, nameOrId, tryGet) { itemsRaw = await tryGet(`instagram:tags:${tag}`, () => ig.feed.tags(tag, 'recent').items(), config.cache.routeExpire, false); break; } - default: { + default: break; - } } return { @@ -83,62 +81,11 @@ module.exports = async (ctx) => { throw e; } - const items = data.itemsRaw.map((item) => { - const { product_type } = item; // carousel_container, feed, clips, igtv - // Content - const summary = item.caption?.text ?? ''; - - let description = ''; - switch (product_type) { - case 'carousel_container': { - const images = item.carousel_media.map((i) => i.image_versions2.candidates[0]); - description = art(path.join(__dirname, '../templates/images.art'), { - summary, - images, - }); - break; - } - case 'clips': - case 'igtv': - description = art(path.join(__dirname, '../templates/video.art'), { - summary, - image: item.image_versions2.candidates[0].url, - video: item.video_versions[0], - }); - break; - case 'feed': { - const images = [item.image_versions2.candidates[0]]; - description = art(path.join(__dirname, '../templates/images.art'), { - summary, - images, - }); - break; - } - default: - throw Error(`Instagram: Unhandled feed type: ${product_type}`); - } - - // Metadata - const url = `https://www.instagram.com/p/${item.code}/`; - const pubDate = parseDate(item.taken_at, 'X'); - const title = summary.split('\n')[0]; - - return { - title, - id: item.pk, - pubDate, - author: item.user.username, - link: url, - summary, - description, - }; - }); - ctx.state.data = { title: data.feedTitle, link: data.feedLink, description: data.feedDescription, - item: items, + item: renderItems(data.itemsRaw), icon: 'https://www.instagram.com/static/images/ico/xxhdpi_launcher.png/99cf3909d459.png', logo: data.feedLogo, image: data.feedLogo, diff --git a/lib/v2/instagram/router.js b/lib/v2/instagram/router.js index eda1408aa..9672c20cd 100644 --- a/lib/v2/instagram/router.js +++ b/lib/v2/instagram/router.js @@ -1,3 +1,4 @@ module.exports = (router) => { router.get('/:category/:key', require('./private-api/index')); + router.get('/2/:category/:key', require('./web-api/index')); }; diff --git a/lib/v2/instagram/web-api/index.js b/lib/v2/instagram/web-api/index.js new file mode 100644 index 000000000..658b8162a --- /dev/null +++ b/lib/v2/instagram/web-api/index.js @@ -0,0 +1,70 @@ +const { CookieJar } = require('tough-cookie'); +const config = require('@/config').value; +const { renderItems } = require('../common-utils'); +const { baseUrl, COOKIE_URL, getUserInfo, getUserFeedItems, getTagsFeedItems } = require('./utils'); + +module.exports = async (ctx) => { + if (!config.instagram || !config.instagram.cookie) { + throw Error('Instagram RSS is disabled due to the lack of relevant config'); + } + const availableCategories = ['user', 'tags']; + const { category, key } = ctx.params; + const { cookie } = config.instagram; + if (!availableCategories.includes(category)) { + throw Error('Such feed is not supported.'); + } + + let cookieJar = await ctx.cache.get('instagram:cookieJar'); + const cacheMiss = !cookieJar; + if (cacheMiss) { + cookieJar = new CookieJar(); + for await (const c of cookie.split('; ')) { + await cookieJar.setCookie(c, COOKIE_URL); + } + } else { + cookieJar = CookieJar.fromJSON(cookieJar); + } + + let feedTitle, feedLink, feedDescription, feedLogo; + let items; + switch (category) { + case 'user': { + const userInfo = await getUserInfo(key, cookieJar, ctx.cache); + + // User feed metadata + const { biography, full_name, id, username } = userInfo; + feedTitle = `${full_name} (@${username}) - Instagram`; + feedDescription = biography; + // exists in web api ?? exist in private api ?? exist in both + feedLogo = userInfo.profile_pic_url_hd ?? userInfo.hd_profile_pic_url_info?.url ?? userInfo.profile_pic_url; + feedLink = `${baseUrl}/${username}`; + + items = await getUserFeedItems(id, username, cookieJar, ctx.cache.tryGet); + break; + } + case 'tags': { + const tag = key; + + feedTitle = `#${tag} - Instagram`; + feedLink = `${baseUrl}/explore/tags/${tag}`; + + items = await getTagsFeedItems(tag, 'recent', cookieJar, ctx.cache.tryGet); + break; + } + default: + break; + } + + await ctx.cache.set('instagram:cookieJar', cookieJar.toJSON(), 31536000); + + ctx.state.data = { + title: feedTitle, + link: feedLink, + description: feedDescription, + item: renderItems(items), + icon: `${baseUrl}/static/images/ico/xxhdpi_launcher.png/99cf3909d459.png`, + logo: feedLogo, + image: feedLogo, + allowEmpty: true, + }; +}; diff --git a/lib/v2/instagram/web-api/utils.js b/lib/v2/instagram/web-api/utils.js new file mode 100644 index 000000000..2bf6bc474 --- /dev/null +++ b/lib/v2/instagram/web-api/utils.js @@ -0,0 +1,99 @@ +const got = require('@/utils/got'); +const config = require('@/config').value; + +const baseUrl = 'https://www.instagram.com'; +const COOKIE_URL = 'https://instagram.com'; +let igWwwClaim; + +const getCSRFTokenFromJar = async (cookieJar) => { + const cookieString = await cookieJar.getCookieString(COOKIE_URL); + return cookieString.match(/csrftoken=([^;]+)/)?.[1]; +}; + +const getHeaders = async (cookieJar) => ({ + 'X-ASBD-ID': 198387, + 'X-CSRFToken': await getCSRFTokenFromJar(cookieJar), + 'X-IG-App-ID': 936619743392459, + 'X-IG-WWW-Claim': igWwwClaim, +}); + +const getUserInfo = async (username, cookieJar, cache) => { + let webProfileInfo; + let id = await cache.get(`instagram:getIdByUsername:${username}`); + let userInfoCache = await cache.get(`instagram:userInfo:${id}`); + + if (!userInfoCache) { + const response = await got(`${baseUrl}/api/v1/users/web_profile_info/`, { + cookieJar, + headers: await getHeaders(cookieJar), + searchParams: { + username, + }, + }); + if (response.url.includes('/accounts/login/')) { + throw Error('Invalid cookie'); + } + igWwwClaim = response.headers['x-ig-set-www-claim'] || igWwwClaim; + + webProfileInfo = response.data.data.user; + id = webProfileInfo.id; + + await cache.set(`instagram:getIdByUsername:${username}`, id, 31536000); // 1 year since it will never change + await cache.set(`instagram:userInfo:${id}`, webProfileInfo); + } + + userInfoCache = typeof userInfoCache === 'string' ? JSON.parse(userInfoCache) : userInfoCache; + + return userInfoCache || webProfileInfo; +}; + +const getUserFeedItems = (id, username, cookieJar, tryGet) => + tryGet( + `instagram:feed:${id}`, + async () => { + const response = await got(`${baseUrl}/api/v1/feed/user/${username}/username/`, { + cookieJar, + headers: await getHeaders(cookieJar), + searchParams: { + count: 30, + }, + }); + // 401 Unauthorized if cookie does not match with IP + igWwwClaim = response.headers['x-ig-set-www-claim'] || igWwwClaim; + + return response.data.items; + }, + config.cache.routeExpire, + false + ); + +const getTagsFeedItems = (tag, tab, cookieJar, tryGet) => + tryGet( + `instagram:tags:${tag}`, + async () => { + const response = await got(`${baseUrl}/api/v1/tags/web_info/`, { + // cookieJar, cookieJar is behaving weirdly here, so we use cookie header instead + headers: { + cookie: await cookieJar.getCookieString(COOKIE_URL), + ...(await getHeaders(cookieJar)), + }, + searchParams: { + tag_name: tag, + }, + }); + // Looks like cookie IP check is not applied to tags + igWwwClaim = response.headers['x-ig-set-www-claim'] || igWwwClaim; + + return response.data.data[tab].sections.flatMap((section) => section.layout_content.medias.map((media) => media.media)); + }, + config.cache.routeExpire, + false + ); + +module.exports = { + baseUrl, + COOKIE_URL, + getUserInfo, + getUserFeedItems, + getTagsFeedItems, +};