feat(route): instagram from cookie (#11952)
* feat(route): instagram 2 * docs: add docs * fix: get tabs * docs: fix tag
This commit is contained in:
parent
a7cbbf559b
commit
68e972464c
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
注意,暂**不支持**两步验证。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
### 运动日记
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
module.exports = {
|
||||
'/:category/:key': ['oppilate', 'DIYgod'],
|
||||
'/2/:category/:key': ['TonyRL'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
module.exports = (router) => {
|
||||
router.get('/:category/:key', require('./private-api/index'));
|
||||
router.get('/2/:category/:key', require('./web-api/index'));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in New Issue