@jackwener/opencli 1.4.1 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-extension.yml +2 -6
- package/.github/workflows/ci.yml +21 -1
- package/README.md +35 -6
- package/README.zh-CN.md +12 -5
- package/SKILL.md +2 -0
- package/dist/browser/cdp.d.ts +2 -1
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.d.ts +4 -1
- package/dist/browser/discover.js +6 -2
- package/dist/browser/errors.d.ts +2 -2
- package/dist/browser/errors.js +4 -12
- package/dist/browser/mcp.d.ts +2 -1
- package/dist/browser/page.d.ts +3 -0
- package/dist/browser/page.js +24 -1
- package/dist/build-manifest.d.ts +2 -0
- package/dist/build-manifest.js +39 -14
- package/dist/build-manifest.test.js +21 -0
- package/dist/capabilityRouting.d.ts +2 -0
- package/dist/capabilityRouting.js +2 -1
- package/dist/cli-manifest.json +1567 -108
- package/dist/cli.js +68 -6
- package/dist/clis/36kr/article.d.ts +1 -0
- package/dist/clis/36kr/article.js +62 -0
- package/dist/clis/36kr/hot.d.ts +3 -0
- package/dist/clis/36kr/hot.js +80 -0
- package/dist/clis/36kr/hot.test.d.ts +1 -0
- package/dist/clis/36kr/hot.test.js +15 -0
- package/dist/clis/36kr/news.d.ts +1 -0
- package/dist/clis/36kr/news.js +51 -0
- package/dist/clis/36kr/news.test.d.ts +1 -0
- package/dist/clis/36kr/news.test.js +85 -0
- package/dist/clis/36kr/search.d.ts +1 -0
- package/dist/clis/36kr/search.js +72 -0
- package/dist/clis/bilibili/comments.d.ts +5 -0
- package/dist/clis/bilibili/comments.js +40 -0
- package/dist/clis/bilibili/comments.test.d.ts +1 -0
- package/dist/clis/bilibili/comments.test.js +82 -0
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/chatgpt/ask.js +29 -14
- package/dist/clis/chatgpt/ax.d.ts +6 -0
- package/dist/clis/chatgpt/ax.js +172 -1
- package/dist/clis/chatgpt/model.d.ts +1 -0
- package/dist/clis/chatgpt/model.js +24 -0
- package/dist/clis/chatgpt/send.js +12 -3
- package/dist/clis/douban/download.d.ts +1 -0
- package/dist/clis/douban/download.js +67 -0
- package/dist/clis/douban/download.test.d.ts +1 -0
- package/dist/clis/douban/download.test.js +170 -0
- package/dist/clis/douban/photos.d.ts +1 -0
- package/dist/clis/douban/photos.js +34 -0
- package/dist/clis/douban/utils.d.ts +25 -0
- package/dist/clis/douban/utils.js +190 -1
- package/dist/clis/douban/utils.test.d.ts +1 -0
- package/dist/clis/douban/utils.test.js +64 -0
- package/dist/clis/imdb/person.d.ts +1 -0
- package/dist/clis/imdb/person.js +203 -0
- package/dist/clis/imdb/reviews.d.ts +1 -0
- package/dist/clis/imdb/reviews.js +88 -0
- package/dist/clis/imdb/search.d.ts +1 -0
- package/dist/clis/imdb/search.js +161 -0
- package/dist/clis/imdb/title.d.ts +1 -0
- package/dist/clis/imdb/title.js +93 -0
- package/dist/clis/imdb/top.d.ts +1 -0
- package/dist/clis/imdb/top.js +53 -0
- package/dist/clis/imdb/trending.d.ts +1 -0
- package/dist/clis/imdb/trending.js +52 -0
- package/dist/clis/imdb/utils.d.ts +46 -0
- package/dist/clis/imdb/utils.js +285 -0
- package/dist/clis/imdb/utils.test.d.ts +1 -0
- package/dist/clis/imdb/utils.test.js +88 -0
- package/dist/clis/jd/item.d.ts +4 -0
- package/dist/clis/jd/item.js +16 -15
- package/dist/clis/jd/item.test.js +16 -1
- package/dist/clis/linux-do/categories.yaml +38 -9
- package/dist/clis/linux-do/category.d.ts +1 -0
- package/dist/clis/linux-do/category.js +36 -0
- package/dist/clis/linux-do/feed.d.ts +45 -0
- package/dist/clis/linux-do/feed.js +397 -0
- package/dist/clis/linux-do/feed.test.d.ts +1 -0
- package/dist/clis/linux-do/feed.test.js +118 -0
- package/dist/clis/linux-do/hot.d.ts +1 -0
- package/dist/clis/linux-do/hot.js +25 -0
- package/dist/clis/linux-do/latest.d.ts +1 -0
- package/dist/clis/linux-do/latest.js +18 -0
- package/dist/clis/linux-do/tags.yaml +41 -0
- package/dist/clis/linux-do/topic.yaml +41 -3
- package/dist/clis/linux-do/user-posts.yaml +67 -0
- package/dist/clis/linux-do/user-topics.yaml +54 -0
- package/dist/clis/paperreview/commands.test.d.ts +3 -0
- package/dist/clis/paperreview/commands.test.js +243 -0
- package/dist/clis/paperreview/feedback.d.ts +1 -0
- package/dist/clis/paperreview/feedback.js +52 -0
- package/dist/clis/paperreview/review.d.ts +1 -0
- package/dist/clis/paperreview/review.js +37 -0
- package/dist/clis/paperreview/submit.d.ts +1 -0
- package/dist/clis/paperreview/submit.js +85 -0
- package/dist/clis/paperreview/utils.d.ts +46 -0
- package/dist/clis/paperreview/utils.js +197 -0
- package/dist/clis/paperreview/utils.test.d.ts +1 -0
- package/dist/clis/paperreview/utils.test.js +49 -0
- package/dist/clis/producthunt/browse.d.ts +1 -0
- package/dist/clis/producthunt/browse.js +99 -0
- package/dist/clis/producthunt/hot.d.ts +1 -0
- package/dist/clis/producthunt/hot.js +110 -0
- package/dist/clis/producthunt/posts.d.ts +1 -0
- package/dist/clis/producthunt/posts.js +28 -0
- package/dist/clis/producthunt/today.d.ts +1 -0
- package/dist/clis/producthunt/today.js +35 -0
- package/dist/clis/producthunt/utils.d.ts +29 -0
- package/dist/clis/producthunt/utils.js +99 -0
- package/dist/clis/producthunt/utils.test.d.ts +1 -0
- package/dist/clis/producthunt/utils.test.js +64 -0
- package/dist/clis/twitter/article.js +4 -28
- package/dist/clis/twitter/likes.d.ts +24 -0
- package/dist/clis/twitter/likes.js +217 -0
- package/dist/clis/twitter/likes.test.d.ts +1 -0
- package/dist/clis/twitter/likes.test.js +85 -0
- package/dist/clis/twitter/profile.js +4 -28
- package/dist/clis/twitter/search.js +2 -1
- package/dist/clis/twitter/search.test.js +2 -0
- package/dist/clis/twitter/shared.d.ts +6 -0
- package/dist/clis/twitter/shared.js +35 -0
- package/dist/clis/twitter/timeline.js +2 -13
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/v2ex/hot.yaml +17 -3
- package/dist/clis/weixin/download.d.ts +17 -0
- package/dist/clis/weixin/download.js +88 -20
- package/dist/clis/weread/book.js +2 -2
- package/dist/clis/weread/commands.test.d.ts +3 -0
- package/dist/clis/weread/commands.test.js +43 -0
- package/dist/clis/weread/highlights.js +2 -2
- package/dist/clis/weread/notebooks.js +2 -2
- package/dist/clis/weread/notes.js +3 -3
- package/dist/clis/weread/shelf.js +2 -2
- package/dist/clis/weread/utils.d.ts +4 -4
- package/dist/clis/weread/utils.js +32 -14
- package/dist/clis/weread/utils.test.js +1 -28
- package/dist/clis/xiaohongshu/comments.d.ts +5 -0
- package/dist/clis/xiaohongshu/comments.js +74 -0
- package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/comments.test.js +79 -0
- package/dist/clis/xiaohongshu/publish.js +179 -47
- package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +131 -0
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/commanderAdapter.d.ts +1 -0
- package/dist/commanderAdapter.js +176 -29
- package/dist/commanderAdapter.test.d.ts +1 -0
- package/dist/commanderAdapter.test.js +62 -0
- package/dist/daemon.js +17 -1
- package/dist/discovery.js +48 -42
- package/dist/doctor.d.ts +2 -2
- package/dist/doctor.js +11 -4
- package/dist/download/index.js +63 -51
- package/dist/download/index.test.js +17 -4
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +17 -34
- package/dist/execution.d.ts +1 -3
- package/dist/execution.js +66 -8
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/external.js +6 -1
- package/dist/hooks.js +2 -0
- package/dist/main.js +6 -0
- package/dist/output.js +5 -1
- package/dist/pipeline/executor.js +3 -4
- package/dist/plugin-manifest.d.ts +70 -0
- package/dist/plugin-manifest.js +160 -0
- package/dist/plugin-manifest.test.d.ts +4 -0
- package/dist/plugin-manifest.test.js +179 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +82 -11
- package/dist/plugin.js +870 -84
- package/dist/plugin.test.js +1032 -17
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +2 -0
- package/dist/runtime-detect.d.ts +21 -0
- package/dist/runtime-detect.js +32 -0
- package/dist/runtime-detect.test.d.ts +1 -0
- package/dist/runtime-detect.test.js +27 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +2 -2
- package/dist/serialization.d.ts +2 -0
- package/dist/serialization.js +6 -0
- package/dist/types.d.ts +3 -0
- package/dist/update-check.d.ts +22 -0
- package/dist/update-check.js +112 -0
- package/dist/weixin-download.test.d.ts +1 -0
- package/dist/weixin-download.test.js +30 -0
- package/dist/weread-private-api-regression.test.d.ts +1 -0
- package/dist/weread-private-api-regression.test.js +122 -0
- package/dist/yaml-schema.d.ts +3 -0
- package/dist/yaml-schema.js +18 -1
- package/docs/.vitepress/config.mts +4 -0
- package/docs/adapters/browser/36kr.md +47 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/adapters/browser/douban.md +14 -0
- package/docs/adapters/browser/imdb.md +47 -0
- package/docs/adapters/browser/jd.md +2 -2
- package/docs/adapters/browser/linux-do.md +181 -20
- package/docs/adapters/browser/paperreview.md +43 -0
- package/docs/adapters/browser/producthunt.md +49 -0
- package/docs/adapters/desktop/chatgpt.md +5 -0
- package/docs/adapters/index.md +6 -2
- package/docs/advanced/download.md +4 -0
- package/docs/advanced/rate-limiter-plugin.md +99 -0
- package/docs/guide/electron-app-cli.md +200 -0
- package/docs/guide/getting-started.md +1 -0
- package/docs/guide/plugins.md +97 -0
- package/docs/zh/guide/electron-app-cli.md +188 -0
- package/docs/zh/guide/getting-started.md +1 -0
- package/docs/zh/guide/plugins.md +65 -0
- package/extension/package.json +1 -0
- package/extension/scripts/package-release.mjs +179 -0
- package/extension/src/background.ts +2 -0
- package/package.json +4 -1
- package/scripts/postinstall.js +10 -0
- package/src/browser/cdp.ts +8 -1
- package/src/browser/discover.ts +8 -3
- package/src/browser/errors.ts +13 -14
- package/src/browser/mcp.ts +2 -1
- package/src/browser/page.ts +24 -1
- package/src/build-manifest.test.ts +23 -0
- package/src/build-manifest.ts +40 -15
- package/src/capabilityRouting.ts +2 -1
- package/src/cli.ts +69 -6
- package/src/clis/36kr/article.ts +69 -0
- package/src/clis/36kr/hot.test.ts +19 -0
- package/src/clis/36kr/hot.ts +100 -0
- package/src/clis/36kr/news.test.ts +90 -0
- package/src/clis/36kr/news.ts +54 -0
- package/src/clis/36kr/search.ts +78 -0
- package/src/clis/bilibili/comments.test.ts +102 -0
- package/src/clis/bilibili/comments.ts +44 -0
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/chatgpt/ask.ts +28 -14
- package/src/clis/chatgpt/ax.ts +180 -1
- package/src/clis/chatgpt/model.ts +27 -0
- package/src/clis/chatgpt/send.ts +16 -6
- package/src/clis/douban/download.test.ts +196 -0
- package/src/clis/douban/download.ts +78 -0
- package/src/clis/douban/photos.ts +36 -0
- package/src/clis/douban/utils.test.ts +97 -0
- package/src/clis/douban/utils.ts +232 -1
- package/src/clis/imdb/person.ts +232 -0
- package/src/clis/imdb/reviews.ts +111 -0
- package/src/clis/imdb/search.ts +179 -0
- package/src/clis/imdb/title.ts +121 -0
- package/src/clis/imdb/top.ts +67 -0
- package/src/clis/imdb/trending.ts +66 -0
- package/src/clis/imdb/utils.test.ts +117 -0
- package/src/clis/imdb/utils.ts +305 -0
- package/src/clis/jd/item.test.ts +18 -1
- package/src/clis/jd/item.ts +18 -15
- package/src/clis/linux-do/categories.yaml +38 -9
- package/src/clis/linux-do/category.ts +37 -0
- package/src/clis/linux-do/feed.test.ts +132 -0
- package/src/clis/linux-do/feed.ts +501 -0
- package/src/clis/linux-do/hot.ts +26 -0
- package/src/clis/linux-do/latest.ts +19 -0
- package/src/clis/linux-do/tags.yaml +41 -0
- package/src/clis/linux-do/topic.yaml +41 -3
- package/src/clis/linux-do/user-posts.yaml +67 -0
- package/src/clis/linux-do/user-topics.yaml +54 -0
- package/src/clis/paperreview/commands.test.ts +283 -0
- package/src/clis/paperreview/feedback.ts +64 -0
- package/src/clis/paperreview/review.ts +47 -0
- package/src/clis/paperreview/submit.ts +119 -0
- package/src/clis/paperreview/utils.test.ts +68 -0
- package/src/clis/paperreview/utils.ts +276 -0
- package/src/clis/producthunt/browse.ts +109 -0
- package/src/clis/producthunt/hot.ts +127 -0
- package/src/clis/producthunt/posts.ts +29 -0
- package/src/clis/producthunt/today.ts +37 -0
- package/src/clis/producthunt/utils.test.ts +72 -0
- package/src/clis/producthunt/utils.ts +122 -0
- package/src/clis/twitter/article.ts +5 -28
- package/src/clis/twitter/likes.test.ts +91 -0
- package/src/clis/twitter/likes.ts +256 -0
- package/src/clis/twitter/profile.ts +5 -28
- package/src/clis/twitter/search.test.ts +2 -0
- package/src/clis/twitter/search.ts +3 -1
- package/src/clis/twitter/shared.ts +45 -0
- package/src/clis/twitter/timeline.ts +2 -13
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/v2ex/hot.yaml +17 -3
- package/src/clis/weixin/download.ts +114 -20
- package/src/clis/weread/book.ts +2 -2
- package/src/clis/weread/commands.test.ts +57 -0
- package/src/clis/weread/highlights.ts +2 -2
- package/src/clis/weread/notebooks.ts +2 -2
- package/src/clis/weread/notes.ts +3 -3
- package/src/clis/weread/shelf.ts +2 -2
- package/src/clis/weread/utils.test.ts +1 -32
- package/src/clis/weread/utils.ts +41 -16
- package/src/clis/xiaohongshu/comments.test.ts +96 -0
- package/src/clis/xiaohongshu/comments.ts +81 -0
- package/src/clis/xiaohongshu/publish.test.ts +151 -0
- package/src/clis/xiaohongshu/publish.ts +206 -54
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/commanderAdapter.test.ts +78 -0
- package/src/commanderAdapter.ts +188 -24
- package/src/daemon.ts +19 -1
- package/src/discovery.ts +49 -48
- package/src/doctor.ts +15 -5
- package/src/download/index.test.ts +14 -4
- package/src/download/index.ts +67 -55
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +26 -63
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +67 -9
- package/src/external.ts +6 -1
- package/src/hooks.ts +1 -0
- package/src/main.ts +7 -0
- package/src/output.ts +3 -1
- package/src/pipeline/executor.ts +4 -6
- package/src/plugin-manifest.test.ts +223 -0
- package/src/plugin-manifest.ts +206 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +1104 -17
- package/src/plugin.ts +1101 -86
- package/src/registry.ts +6 -1
- package/src/runtime-detect.test.ts +30 -0
- package/src/runtime-detect.ts +36 -0
- package/src/runtime.ts +3 -3
- package/src/serialization.ts +4 -0
- package/src/types.ts +3 -0
- package/src/update-check.ts +114 -0
- package/src/weixin-download.test.ts +64 -0
- package/src/weread-private-api-regression.test.ts +150 -0
- package/src/yaml-schema.ts +20 -0
- package/tests/e2e/browser-auth.test.ts +13 -9
- package/tests/e2e/browser-public-extended.test.ts +1 -1
- package/tests/e2e/browser-public.test.ts +62 -4
- package/tests/e2e/helpers.ts +2 -1
- package/tests/e2e/public-commands.test.ts +37 -3
- package/tests/smoke/api-health.test.ts +1 -1
- package/vitest.config.ts +10 -0
- package/dist/clis/linux-do/category.yaml +0 -51
- package/dist/clis/linux-do/hot.yaml +0 -50
- package/dist/clis/linux-do/latest.yaml +0 -40
- package/src/clis/linux-do/category.yaml +0 -51
- package/src/clis/linux-do/hot.yaml +0 -50
- package/src/clis/linux-do/latest.yaml +0 -40
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseFeed, pickVoteCount, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
3
|
+
const SAMPLE_ATOM = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
5
|
+
<title>Product Hunt</title>
|
|
6
|
+
<entry>
|
|
7
|
+
<id>tag:www.producthunt.com,2005:Post/1001</id>
|
|
8
|
+
<published>2026-03-26T10:00:00-07:00</published>
|
|
9
|
+
<title>Awesome AI Tool</title>
|
|
10
|
+
<content type="html"><p>The best AI tool ever made</p><p><a href="...">Discussion</a></p></content>
|
|
11
|
+
<author><name>Jane Doe</name></author>
|
|
12
|
+
<link rel="alternate" type="text/html" href="https://www.producthunt.com/products/awesome-ai-tool"/>
|
|
13
|
+
</entry>
|
|
14
|
+
<entry>
|
|
15
|
+
<id>tag:www.producthunt.com,2005:Post/1002</id>
|
|
16
|
+
<published>2026-03-25T08:00:00-07:00</published>
|
|
17
|
+
<title>Dev Helper</title>
|
|
18
|
+
<content type="html"><p>Speeds up your workflow</p></content>
|
|
19
|
+
<author><name>John Smith</name></author>
|
|
20
|
+
<link rel="alternate" type="text/html" href="https://www.producthunt.com/products/dev-helper"/>
|
|
21
|
+
</entry>
|
|
22
|
+
</feed>`;
|
|
23
|
+
describe('parseFeed', () => {
|
|
24
|
+
it('parses entries into ranked posts', () => {
|
|
25
|
+
const posts = parseFeed(SAMPLE_ATOM);
|
|
26
|
+
expect(posts).toHaveLength(2);
|
|
27
|
+
expect(posts[0].rank).toBe(1);
|
|
28
|
+
expect(posts[0].name).toBe('Awesome AI Tool');
|
|
29
|
+
expect(posts[0].author).toBe('Jane Doe');
|
|
30
|
+
expect(posts[0].date).toBe('2026-03-26');
|
|
31
|
+
expect(posts[0].url).toBe('https://www.producthunt.com/products/awesome-ai-tool');
|
|
32
|
+
expect(posts[0].tagline).toContain('best AI tool');
|
|
33
|
+
});
|
|
34
|
+
it('strips HTML and Discussion link from tagline', () => {
|
|
35
|
+
const posts = parseFeed(SAMPLE_ATOM);
|
|
36
|
+
expect(posts[0].tagline).not.toContain('<p>');
|
|
37
|
+
expect(posts[0].tagline).not.toContain('Discussion');
|
|
38
|
+
});
|
|
39
|
+
it('returns empty array for empty feed', () => {
|
|
40
|
+
expect(parseFeed('<feed></feed>')).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
it('assigns sequential ranks', () => {
|
|
43
|
+
const posts = parseFeed(SAMPLE_ATOM);
|
|
44
|
+
expect(posts.map(p => p.rank)).toEqual([1, 2]);
|
|
45
|
+
});
|
|
46
|
+
it('prefers vote-like candidates over unrelated numeric badges', () => {
|
|
47
|
+
const votes = pickVoteCount([
|
|
48
|
+
{ text: '12', className: 'font-semibold', inButton: false, inReviewLink: false },
|
|
49
|
+
{ text: '98', className: 'vote-button', inButton: true, inReviewLink: false },
|
|
50
|
+
]);
|
|
51
|
+
expect(votes).toBe('98');
|
|
52
|
+
});
|
|
53
|
+
it('ignores numbers inside review links', () => {
|
|
54
|
+
const votes = pickVoteCount([
|
|
55
|
+
{ text: '120', className: 'text-secondary', inButton: false, inReviewLink: true },
|
|
56
|
+
{ text: '45', className: 'vote-button', inButton: true, inReviewLink: false },
|
|
57
|
+
]);
|
|
58
|
+
expect(votes).toBe('45');
|
|
59
|
+
});
|
|
60
|
+
it('shares category slugs across commands', () => {
|
|
61
|
+
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('developer-tools');
|
|
62
|
+
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('ai-agents');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
4
|
+
const TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
|
|
3
5
|
cli({
|
|
4
6
|
site: 'twitter',
|
|
5
7
|
name: 'article',
|
|
@@ -20,6 +22,7 @@ cli({
|
|
|
20
22
|
// Navigate to the tweet page for cookie context
|
|
21
23
|
await page.goto(`https://x.com/i/status/${tweetId}`);
|
|
22
24
|
await page.wait(3);
|
|
25
|
+
const queryId = await resolveTwitterQueryId(page, 'TweetResultByRestId', TWEET_RESULT_BY_REST_ID_QUERY_ID);
|
|
23
26
|
const result = await page.evaluate(`
|
|
24
27
|
async () => {
|
|
25
28
|
const tweetId = "${tweetId}";
|
|
@@ -54,34 +57,7 @@ cli({
|
|
|
54
57
|
withArticlePlainText: true,
|
|
55
58
|
});
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
async function resolveQueryId(operationName, fallbackId) {
|
|
59
|
-
try {
|
|
60
|
-
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
61
|
-
if (ghResp.ok) {
|
|
62
|
-
const data = await ghResp.json();
|
|
63
|
-
const entry = data[operationName];
|
|
64
|
-
if (entry && entry.queryId) return entry.queryId;
|
|
65
|
-
}
|
|
66
|
-
} catch {}
|
|
67
|
-
try {
|
|
68
|
-
const scripts = performance.getEntriesByType('resource')
|
|
69
|
-
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
70
|
-
.map(r => r.name);
|
|
71
|
-
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
72
|
-
try {
|
|
73
|
-
const text = await (await fetch(scriptUrl)).text();
|
|
74
|
-
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
75
|
-
const m = text.match(re);
|
|
76
|
-
if (m) return m[1];
|
|
77
|
-
} catch {}
|
|
78
|
-
}
|
|
79
|
-
} catch {}
|
|
80
|
-
return fallbackId;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const queryId = await resolveQueryId('TweetResultByRestId', '7xflPyRiUxGVbJd4uWmbfg');
|
|
84
|
-
const url = '/i/api/graphql/' + queryId + '/TweetResultByRestId?variables='
|
|
60
|
+
const url = '/i/api/graphql/' + ${JSON.stringify(queryId)} + '/TweetResultByRestId?variables='
|
|
85
61
|
+ encodeURIComponent(variables)
|
|
86
62
|
+ '&features=' + encodeURIComponent(features)
|
|
87
63
|
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sanitizeQueryId } from './shared.js';
|
|
2
|
+
interface LikedTweet {
|
|
3
|
+
id: string;
|
|
4
|
+
author: string;
|
|
5
|
+
name: string;
|
|
6
|
+
text: string;
|
|
7
|
+
likes: number;
|
|
8
|
+
retweets: number;
|
|
9
|
+
created_at: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
declare function buildLikesUrl(queryId: string, userId: string, count: number, cursor?: string | null): string;
|
|
13
|
+
declare function buildUserByScreenNameUrl(queryId: string, screenName: string): string;
|
|
14
|
+
declare function parseLikes(data: any, seen: Set<string>): {
|
|
15
|
+
tweets: LikedTweet[];
|
|
16
|
+
nextCursor: string | null;
|
|
17
|
+
};
|
|
18
|
+
export declare const __test__: {
|
|
19
|
+
sanitizeQueryId: typeof sanitizeQueryId;
|
|
20
|
+
buildLikesUrl: typeof buildLikesUrl;
|
|
21
|
+
buildUserByScreenNameUrl: typeof buildUserByScreenNameUrl;
|
|
22
|
+
parseLikes: typeof parseLikes;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
3
|
+
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
|
|
4
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
|
+
const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
|
|
6
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
7
|
+
const FEATURES = {
|
|
8
|
+
rweb_video_screen_enabled: false,
|
|
9
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
10
|
+
responsive_web_profile_redirect_enabled: false,
|
|
11
|
+
rweb_tipjar_consumption_enabled: false,
|
|
12
|
+
verified_phone_label_enabled: false,
|
|
13
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
14
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
15
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
16
|
+
premium_content_api_read_enabled: false,
|
|
17
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
18
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
19
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
20
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
21
|
+
responsive_web_jetfuel_frame: true,
|
|
22
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
23
|
+
responsive_web_grok_annotations_enabled: true,
|
|
24
|
+
articles_preview_enabled: true,
|
|
25
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
26
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
27
|
+
view_counts_everywhere_api_enabled: true,
|
|
28
|
+
longform_notetweets_consumption_enabled: true,
|
|
29
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
30
|
+
tweet_awards_web_tipping_enabled: false,
|
|
31
|
+
content_disclosure_indicator_enabled: true,
|
|
32
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
33
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
34
|
+
responsive_web_grok_analysis_button_from_backend: true,
|
|
35
|
+
post_ctas_fetch_enabled: false,
|
|
36
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
37
|
+
standardized_nudges_misinfo: true,
|
|
38
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
39
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
40
|
+
longform_notetweets_inline_media_enabled: false,
|
|
41
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
42
|
+
responsive_web_grok_imagine_annotation_enabled: true,
|
|
43
|
+
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
|
44
|
+
responsive_web_enhance_cards_enabled: false
|
|
45
|
+
};
|
|
46
|
+
function buildLikesUrl(queryId, userId, count, cursor) {
|
|
47
|
+
const vars = {
|
|
48
|
+
userId,
|
|
49
|
+
count,
|
|
50
|
+
includePromotedContent: false,
|
|
51
|
+
withClientEventToken: false,
|
|
52
|
+
withBirdwatchNotes: false,
|
|
53
|
+
withVoice: true
|
|
54
|
+
};
|
|
55
|
+
if (cursor)
|
|
56
|
+
vars.cursor = cursor;
|
|
57
|
+
return `/i/api/graphql/${queryId}/Likes`
|
|
58
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
59
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
60
|
+
}
|
|
61
|
+
function buildUserByScreenNameUrl(queryId, screenName) {
|
|
62
|
+
const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
|
|
63
|
+
const feats = JSON.stringify({
|
|
64
|
+
hidden_profile_subscriptions_enabled: true,
|
|
65
|
+
rweb_tipjar_consumption_enabled: true,
|
|
66
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
67
|
+
verified_phone_label_enabled: false,
|
|
68
|
+
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
69
|
+
subscriptions_verification_info_verified_since_enabled: true,
|
|
70
|
+
highlights_tweets_tab_ui_enabled: true,
|
|
71
|
+
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
72
|
+
subscriptions_feature_can_gift_premium: true,
|
|
73
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
74
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
75
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
76
|
+
});
|
|
77
|
+
return `/i/api/graphql/${queryId}/UserByScreenName`
|
|
78
|
+
+ `?variables=${encodeURIComponent(vars)}`
|
|
79
|
+
+ `&features=${encodeURIComponent(feats)}`;
|
|
80
|
+
}
|
|
81
|
+
function extractLikedTweet(result, seen) {
|
|
82
|
+
if (!result)
|
|
83
|
+
return null;
|
|
84
|
+
const tw = result.tweet || result;
|
|
85
|
+
const legacy = tw.legacy || {};
|
|
86
|
+
if (!tw.rest_id || seen.has(tw.rest_id))
|
|
87
|
+
return null;
|
|
88
|
+
seen.add(tw.rest_id);
|
|
89
|
+
const user = tw.core?.user_results?.result;
|
|
90
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
|
91
|
+
const displayName = user?.legacy?.name || user?.core?.name || '';
|
|
92
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
93
|
+
return {
|
|
94
|
+
id: tw.rest_id,
|
|
95
|
+
author: screenName,
|
|
96
|
+
name: displayName,
|
|
97
|
+
text: noteText || legacy.full_text || '',
|
|
98
|
+
likes: legacy.favorite_count || 0,
|
|
99
|
+
retweets: legacy.retweet_count || 0,
|
|
100
|
+
created_at: legacy.created_at || '',
|
|
101
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function parseLikes(data, seen) {
|
|
105
|
+
const tweets = [];
|
|
106
|
+
let nextCursor = null;
|
|
107
|
+
const instructions = data?.data?.user?.result?.timeline_v2?.timeline?.instructions
|
|
108
|
+
|| data?.data?.user?.result?.timeline?.timeline?.instructions
|
|
109
|
+
|| [];
|
|
110
|
+
for (const inst of instructions) {
|
|
111
|
+
for (const entry of inst.entries || []) {
|
|
112
|
+
const content = entry.content;
|
|
113
|
+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
|
114
|
+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore')
|
|
115
|
+
nextCursor = content.value;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
119
|
+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const direct = extractLikedTweet(content?.itemContent?.tweet_results?.result, seen);
|
|
123
|
+
if (direct) {
|
|
124
|
+
tweets.push(direct);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const item of content?.items || []) {
|
|
128
|
+
const nested = extractLikedTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
129
|
+
if (nested)
|
|
130
|
+
tweets.push(nested);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { tweets, nextCursor };
|
|
135
|
+
}
|
|
136
|
+
cli({
|
|
137
|
+
site: 'twitter',
|
|
138
|
+
name: 'likes',
|
|
139
|
+
description: 'Fetch liked tweets of a Twitter user',
|
|
140
|
+
domain: 'x.com',
|
|
141
|
+
strategy: Strategy.COOKIE,
|
|
142
|
+
browser: true,
|
|
143
|
+
args: [
|
|
144
|
+
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
|
145
|
+
{ name: 'limit', type: 'int', default: 20 },
|
|
146
|
+
],
|
|
147
|
+
columns: ['author', 'name', 'text', 'likes', 'url'],
|
|
148
|
+
func: async (page, kwargs) => {
|
|
149
|
+
const limit = kwargs.limit || 20;
|
|
150
|
+
let username = (kwargs.username || '').replace(/^@/, '');
|
|
151
|
+
await page.goto('https://x.com');
|
|
152
|
+
await page.wait(3);
|
|
153
|
+
const ct0 = await page.evaluate(`() => {
|
|
154
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
155
|
+
}`);
|
|
156
|
+
if (!ct0)
|
|
157
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
158
|
+
// If no username provided, detect the logged-in user
|
|
159
|
+
if (!username) {
|
|
160
|
+
const href = await page.evaluate(`() => {
|
|
161
|
+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
162
|
+
return link ? link.getAttribute('href') : null;
|
|
163
|
+
}`);
|
|
164
|
+
if (!href)
|
|
165
|
+
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
166
|
+
username = href.replace('/', '');
|
|
167
|
+
}
|
|
168
|
+
const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
|
|
169
|
+
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
170
|
+
const headers = JSON.stringify({
|
|
171
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
172
|
+
'X-Csrf-Token': ct0,
|
|
173
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
174
|
+
'X-Twitter-Active-User': 'yes',
|
|
175
|
+
});
|
|
176
|
+
// Get userId from screen_name
|
|
177
|
+
const userId = await page.evaluate(`async () => {
|
|
178
|
+
const screenName = ${JSON.stringify(username)};
|
|
179
|
+
const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, username))};
|
|
180
|
+
const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
|
|
181
|
+
if (!resp.ok) return null;
|
|
182
|
+
const d = await resp.json();
|
|
183
|
+
return d.data?.user?.result?.rest_id || null;
|
|
184
|
+
}`);
|
|
185
|
+
if (!userId) {
|
|
186
|
+
throw new CommandExecutionError(`Could not find user @${username}`);
|
|
187
|
+
}
|
|
188
|
+
const allTweets = [];
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
let cursor = null;
|
|
191
|
+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
|
192
|
+
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
193
|
+
const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor);
|
|
194
|
+
const data = await page.evaluate(`async () => {
|
|
195
|
+
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
196
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
197
|
+
}`);
|
|
198
|
+
if (data?.error) {
|
|
199
|
+
if (allTweets.length === 0)
|
|
200
|
+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
const { tweets, nextCursor } = parseLikes(data, seen);
|
|
204
|
+
allTweets.push(...tweets);
|
|
205
|
+
if (!nextCursor || nextCursor === cursor)
|
|
206
|
+
break;
|
|
207
|
+
cursor = nextCursor;
|
|
208
|
+
}
|
|
209
|
+
return allTweets.slice(0, limit);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
export const __test__ = {
|
|
213
|
+
sanitizeQueryId,
|
|
214
|
+
buildLikesUrl,
|
|
215
|
+
buildUserByScreenNameUrl,
|
|
216
|
+
parseLikes,
|
|
217
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './likes.js';
|
|
3
|
+
describe('twitter likes helpers', () => {
|
|
4
|
+
it('falls back when queryId contains unsafe characters', () => {
|
|
5
|
+
expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
|
|
6
|
+
expect(__test__.sanitizeQueryId('bad"id', 'fallback')).toBe('fallback');
|
|
7
|
+
expect(__test__.sanitizeQueryId('bad/id', 'fallback')).toBe('fallback');
|
|
8
|
+
expect(__test__.sanitizeQueryId(null, 'fallback')).toBe('fallback');
|
|
9
|
+
});
|
|
10
|
+
it('builds likes url with the provided queryId', () => {
|
|
11
|
+
const url = __test__.buildLikesUrl('query123', '42', 20, 'cursor-1');
|
|
12
|
+
expect(url).toContain('/i/api/graphql/query123/Likes');
|
|
13
|
+
expect(decodeURIComponent(url)).toContain('"userId":"42"');
|
|
14
|
+
expect(decodeURIComponent(url)).toContain('"cursor":"cursor-1"');
|
|
15
|
+
});
|
|
16
|
+
it('parses likes timeline entries and bottom cursor', () => {
|
|
17
|
+
const payload = {
|
|
18
|
+
data: {
|
|
19
|
+
user: {
|
|
20
|
+
result: {
|
|
21
|
+
timeline_v2: {
|
|
22
|
+
timeline: {
|
|
23
|
+
instructions: [
|
|
24
|
+
{
|
|
25
|
+
entries: [
|
|
26
|
+
{
|
|
27
|
+
entryId: 'tweet-1',
|
|
28
|
+
content: {
|
|
29
|
+
itemContent: {
|
|
30
|
+
tweet_results: {
|
|
31
|
+
result: {
|
|
32
|
+
rest_id: '1',
|
|
33
|
+
legacy: {
|
|
34
|
+
full_text: 'liked post',
|
|
35
|
+
favorite_count: 7,
|
|
36
|
+
retweet_count: 2,
|
|
37
|
+
created_at: 'now',
|
|
38
|
+
},
|
|
39
|
+
core: {
|
|
40
|
+
user_results: {
|
|
41
|
+
result: {
|
|
42
|
+
legacy: {
|
|
43
|
+
screen_name: 'alice',
|
|
44
|
+
name: 'Alice',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
entryId: 'cursor-bottom-1',
|
|
56
|
+
content: {
|
|
57
|
+
entryType: 'TimelineTimelineCursor',
|
|
58
|
+
cursorType: 'Bottom',
|
|
59
|
+
value: 'cursor-next',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const result = __test__.parseLikes(payload, new Set());
|
|
72
|
+
expect(result.nextCursor).toBe('cursor-next');
|
|
73
|
+
expect(result.tweets).toHaveLength(1);
|
|
74
|
+
expect(result.tweets[0]).toMatchObject({
|
|
75
|
+
id: '1',
|
|
76
|
+
author: 'alice',
|
|
77
|
+
name: 'Alice',
|
|
78
|
+
text: 'liked post',
|
|
79
|
+
likes: 7,
|
|
80
|
+
retweets: 2,
|
|
81
|
+
created_at: 'now',
|
|
82
|
+
url: 'https://x.com/alice/status/1',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
4
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
3
5
|
cli({
|
|
4
6
|
site: 'twitter',
|
|
5
7
|
name: 'profile',
|
|
@@ -28,6 +30,7 @@ cli({
|
|
|
28
30
|
// Navigate directly to the user's profile page (gives us cookie context)
|
|
29
31
|
await page.goto(`https://x.com/${username}`);
|
|
30
32
|
await page.wait(3);
|
|
33
|
+
const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
31
34
|
const result = await page.evaluate(`
|
|
32
35
|
async () => {
|
|
33
36
|
const screenName = "${username}";
|
|
@@ -61,34 +64,7 @@ cli({
|
|
|
61
64
|
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
62
65
|
});
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
async function resolveQueryId(operationName, fallbackId) {
|
|
66
|
-
try {
|
|
67
|
-
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
68
|
-
if (ghResp.ok) {
|
|
69
|
-
const data = await ghResp.json();
|
|
70
|
-
const entry = data[operationName];
|
|
71
|
-
if (entry && entry.queryId) return entry.queryId;
|
|
72
|
-
}
|
|
73
|
-
} catch {}
|
|
74
|
-
try {
|
|
75
|
-
const scripts = performance.getEntriesByType('resource')
|
|
76
|
-
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
77
|
-
.map(r => r.name);
|
|
78
|
-
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
79
|
-
try {
|
|
80
|
-
const text = await (await fetch(scriptUrl)).text();
|
|
81
|
-
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
82
|
-
const m = text.match(re);
|
|
83
|
-
if (m) return m[1];
|
|
84
|
-
} catch {}
|
|
85
|
-
}
|
|
86
|
-
} catch {}
|
|
87
|
-
return fallbackId;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const queryId = await resolveQueryId('UserByScreenName', 'qRednkZG-rn1P6b48NINmQ');
|
|
91
|
-
const url = '/i/api/graphql/' + queryId + '/UserByScreenName?variables='
|
|
67
|
+
const url = '/i/api/graphql/' + ${JSON.stringify(queryId)} + '/UserByScreenName?variables='
|
|
92
68
|
+ encodeURIComponent(variables)
|
|
93
69
|
+ '&features=' + encodeURIComponent(features);
|
|
94
70
|
|
|
@@ -40,7 +40,7 @@ cli({
|
|
|
40
40
|
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'] },
|
|
41
41
|
{ name: 'limit', type: 'int', default: 15 },
|
|
42
42
|
],
|
|
43
|
-
columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
|
|
43
|
+
columns: ['id', 'author', 'text', 'created_at', 'likes', 'views', 'url'],
|
|
44
44
|
func: async (page, kwargs) => {
|
|
45
45
|
const query = kwargs.query;
|
|
46
46
|
const filter = kwargs.filter === 'live' ? 'live' : 'top';
|
|
@@ -91,6 +91,7 @@ cli({
|
|
|
91
91
|
id: tweet.rest_id,
|
|
92
92
|
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
|
|
93
93
|
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
|
|
94
|
+
created_at: tweet.legacy?.created_at || '',
|
|
94
95
|
likes: tweet.legacy?.favorite_count || 0,
|
|
95
96
|
views: tweet.views?.count || '0',
|
|
96
97
|
url: `https://x.com/i/status/${tweet.rest_id}`
|
|
@@ -36,6 +36,7 @@ describe('twitter search command', () => {
|
|
|
36
36
|
legacy: {
|
|
37
37
|
full_text: 'hello world',
|
|
38
38
|
favorite_count: 7,
|
|
39
|
+
created_at: 'Thu Mar 26 10:30:00 +0000 2026',
|
|
39
40
|
},
|
|
40
41
|
core: {
|
|
41
42
|
user_results: {
|
|
@@ -70,6 +71,7 @@ describe('twitter search command', () => {
|
|
|
70
71
|
id: '1',
|
|
71
72
|
author: 'alice',
|
|
72
73
|
text: 'hello world',
|
|
74
|
+
created_at: 'Thu Mar 26 10:30:00 +0000 2026',
|
|
73
75
|
likes: 7,
|
|
74
76
|
views: '12',
|
|
75
77
|
url: 'https://x.com/i/status/1',
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IPage } from '../../types.js';
|
|
2
|
+
export declare function sanitizeQueryId(resolved: unknown, fallbackId: string): string;
|
|
3
|
+
export declare function resolveTwitterQueryId(page: Pick<IPage, 'evaluate'>, operationName: string, fallbackId: string): Promise<string>;
|
|
4
|
+
export declare const __test__: {
|
|
5
|
+
sanitizeQueryId: typeof sanitizeQueryId;
|
|
6
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const QUERY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
2
|
+
export function sanitizeQueryId(resolved, fallbackId) {
|
|
3
|
+
return typeof resolved === 'string' && QUERY_ID_PATTERN.test(resolved) ? resolved : fallbackId;
|
|
4
|
+
}
|
|
5
|
+
export async function resolveTwitterQueryId(page, operationName, fallbackId) {
|
|
6
|
+
const resolved = await page.evaluate(`async () => {
|
|
7
|
+
const operationName = ${JSON.stringify(operationName)};
|
|
8
|
+
try {
|
|
9
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
10
|
+
if (ghResp.ok) {
|
|
11
|
+
const data = await ghResp.json();
|
|
12
|
+
const entry = data?.[operationName];
|
|
13
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
try {
|
|
17
|
+
const scripts = performance.getEntriesByType('resource')
|
|
18
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
19
|
+
.map(r => r.name);
|
|
20
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
21
|
+
try {
|
|
22
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
23
|
+
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
24
|
+
const match = text.match(re);
|
|
25
|
+
if (match) return match[1];
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return null;
|
|
30
|
+
}`);
|
|
31
|
+
return sanitizeQueryId(resolved, fallbackId);
|
|
32
|
+
}
|
|
33
|
+
export const __test__ = {
|
|
34
|
+
sanitizeQueryId,
|
|
35
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
3
4
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
4
5
|
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
6
|
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
|
@@ -162,19 +163,7 @@ cli({
|
|
|
162
163
|
if (!ct0)
|
|
163
164
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
164
165
|
// Dynamically resolve queryId for the selected endpoint
|
|
165
|
-
const
|
|
166
|
-
try {
|
|
167
|
-
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
168
|
-
if (ghResp.ok) {
|
|
169
|
-
const data = await ghResp.json();
|
|
170
|
-
const entry = data['${endpoint}'];
|
|
171
|
-
if (entry && entry.queryId) return entry.queryId;
|
|
172
|
-
}
|
|
173
|
-
} catch {}
|
|
174
|
-
return null;
|
|
175
|
-
}`);
|
|
176
|
-
// Validate queryId format to prevent injection from untrusted upstream
|
|
177
|
-
const queryId = typeof resolved === 'string' && /^[A-Za-z0-9_-]+$/.test(resolved) ? resolved : fallbackQueryId;
|
|
166
|
+
const queryId = await resolveTwitterQueryId(page, endpoint, fallbackQueryId);
|
|
178
167
|
// Build auth headers
|
|
179
168
|
const headers = JSON.stringify({
|
|
180
169
|
Authorization: `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|