feat(route): instagram from cookie (#11952)

* feat(route): instagram 2

* docs: add docs

* fix: get tabs

* docs: fix tag
This commit is contained in:
Tony 2023-02-25 05:12:06 -02:00 committed by GitHub
parent a7cbbf559b
commit 68e972464c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 259 additions and 71 deletions

View File

@ -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.

View File

@ -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
<RouteEn author="oppilate DIYgod" example="/instagram/user/stefaniejoosten" path="/instagram/:category/:key" :paramsDesc="['Feed category, see table below','Username / Hashtag name']" radar="1" anticrawler="1">
<RouteEn author="oppilate DIYgod" example="/instagram/user/stefaniejoosten" path="/instagram/:category/:key" :paramsDesc="['Feed category, see table below','Username / Hashtag name']" radar="1" anticrawler="1" selfhost="1">
| User timeline | Hashtag |
| ---------- | ---- |
@ -168,6 +168,10 @@ It's highly recommended to deploy with Redis cache enabled.
</RouteEn>
### User Profile / Hashtag - Cookie
<RouteEn author="TonyRL" example="/instagram/2/user/stefaniejoosten" path="/instagram/2/:category/:key" :paramsDesc="['Feed category, see table above','Username / Hashtag name']" radar="1" anticrawler="1" selfhost="1" />
## Lofter
### User

View File

@ -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
注意,暂**不支持**两步验证。

View File

@ -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
<Route author="oppilate DIYgod" example="/instagram/user/stefaniejoosten" path="/instagram/:category/:key" :paramsDesc="['类别,见下表', '用户名/标签名']" radar="1" anticrawler="1" radar="1">
@ -508,6 +508,10 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性
</Route>
### 用户 / 标签 - Cookie
<Route author="TonyRL" example="/instagram/2/user/stefaniejoosten" path="/instagram/2/:category/:key" :paramsDesc="['类别,见上表', '用户名/标签名']" radar="1" anticrawler="1" selfhost="1" />
## Keep
### 运动日记

View File

@ -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,

View File

@ -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,
};

View File

@ -1,3 +1,4 @@
module.exports = {
'/:category/:key': ['oppilate', 'DIYgod'],
'/2/:category/:key': ['TonyRL'],
};

View File

@ -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,

View File

@ -1,3 +1,4 @@
module.exports = (router) => {
router.get('/:category/:key', require('./private-api/index'));
router.get('/2/:category/:key', require('./web-api/index'));
};

View File

@ -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 <a href="https://docs.rsshub.app/install/#pei-zhi-bu-fen-rss-mo-kuai-pei-zhi">relevant config</a>');
}
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,
};
};

View File

@ -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,
};