@jackwener/opencli 1.4.1 → 1.5.0
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/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/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 +1111 -112
- package/dist/cli.js +34 -3
- 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/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/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 +114 -18
- package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +119 -0
- 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 +8 -14
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +9 -2
- package/dist/download/index.js +63 -51
- package/dist/download/index.test.js +17 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +15 -32
- package/dist/execution.d.ts +1 -3
- package/dist/execution.js +21 -1
- package/dist/hooks.js +2 -0
- package/dist/main.js +5 -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.d.ts +38 -5
- package/dist/plugin.js +267 -33
- package/dist/plugin.test.js +220 -3
- 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.js +1 -1
- package/dist/serialization.d.ts +2 -0
- package/dist/serialization.js +6 -0
- package/dist/types.d.ts +1 -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/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 +87 -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 +2 -1
- package/src/browser/discover.ts +8 -3
- package/src/browser/errors.ts +13 -14
- package/src/browser/mcp.ts +2 -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 +35 -3
- 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/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/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 +137 -0
- package/src/clis/xiaohongshu/publish.ts +129 -18
- package/src/commanderAdapter.test.ts +78 -0
- package/src/commanderAdapter.ts +188 -24
- package/src/daemon.ts +19 -1
- package/src/discovery.ts +8 -15
- package/src/doctor.ts +13 -2
- package/src/download/index.test.ts +14 -4
- package/src/download/index.ts +67 -55
- package/src/errors.ts +25 -66
- package/src/execution.ts +28 -3
- package/src/hooks.ts +1 -0
- package/src/main.ts +6 -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.test.ts +246 -2
- package/src/plugin.ts +338 -36
- 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 +1 -1
- package/src/serialization.ts +4 -0
- package/src/types.ts +1 -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,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseFeed, pickVoteCount, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const SAMPLE_ATOM = `<?xml version="1.0" encoding="UTF-8"?>
|
|
5
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
6
|
+
<title>Product Hunt</title>
|
|
7
|
+
<entry>
|
|
8
|
+
<id>tag:www.producthunt.com,2005:Post/1001</id>
|
|
9
|
+
<published>2026-03-26T10:00:00-07:00</published>
|
|
10
|
+
<title>Awesome AI Tool</title>
|
|
11
|
+
<content type="html"><p>The best AI tool ever made</p><p><a href="...">Discussion</a></p></content>
|
|
12
|
+
<author><name>Jane Doe</name></author>
|
|
13
|
+
<link rel="alternate" type="text/html" href="https://www.producthunt.com/products/awesome-ai-tool"/>
|
|
14
|
+
</entry>
|
|
15
|
+
<entry>
|
|
16
|
+
<id>tag:www.producthunt.com,2005:Post/1002</id>
|
|
17
|
+
<published>2026-03-25T08:00:00-07:00</published>
|
|
18
|
+
<title>Dev Helper</title>
|
|
19
|
+
<content type="html"><p>Speeds up your workflow</p></content>
|
|
20
|
+
<author><name>John Smith</name></author>
|
|
21
|
+
<link rel="alternate" type="text/html" href="https://www.producthunt.com/products/dev-helper"/>
|
|
22
|
+
</entry>
|
|
23
|
+
</feed>`;
|
|
24
|
+
|
|
25
|
+
describe('parseFeed', () => {
|
|
26
|
+
it('parses entries into ranked posts', () => {
|
|
27
|
+
const posts = parseFeed(SAMPLE_ATOM);
|
|
28
|
+
expect(posts).toHaveLength(2);
|
|
29
|
+
expect(posts[0].rank).toBe(1);
|
|
30
|
+
expect(posts[0].name).toBe('Awesome AI Tool');
|
|
31
|
+
expect(posts[0].author).toBe('Jane Doe');
|
|
32
|
+
expect(posts[0].date).toBe('2026-03-26');
|
|
33
|
+
expect(posts[0].url).toBe('https://www.producthunt.com/products/awesome-ai-tool');
|
|
34
|
+
expect(posts[0].tagline).toContain('best AI tool');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('strips HTML and Discussion link from tagline', () => {
|
|
38
|
+
const posts = parseFeed(SAMPLE_ATOM);
|
|
39
|
+
expect(posts[0].tagline).not.toContain('<p>');
|
|
40
|
+
expect(posts[0].tagline).not.toContain('Discussion');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns empty array for empty feed', () => {
|
|
44
|
+
expect(parseFeed('<feed></feed>')).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('assigns sequential ranks', () => {
|
|
48
|
+
const posts = parseFeed(SAMPLE_ATOM);
|
|
49
|
+
expect(posts.map(p => p.rank)).toEqual([1, 2]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('prefers vote-like candidates over unrelated numeric badges', () => {
|
|
53
|
+
const votes = pickVoteCount([
|
|
54
|
+
{ text: '12', className: 'font-semibold', inButton: false, inReviewLink: false },
|
|
55
|
+
{ text: '98', className: 'vote-button', inButton: true, inReviewLink: false },
|
|
56
|
+
]);
|
|
57
|
+
expect(votes).toBe('98');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('ignores numbers inside review links', () => {
|
|
61
|
+
const votes = pickVoteCount([
|
|
62
|
+
{ text: '120', className: 'text-secondary', inButton: false, inReviewLink: true },
|
|
63
|
+
{ text: '45', className: 'vote-button', inButton: true, inReviewLink: false },
|
|
64
|
+
]);
|
|
65
|
+
expect(votes).toBe('45');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shares category slugs across commands', () => {
|
|
69
|
+
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('developer-tools');
|
|
70
|
+
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('ai-agents');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product Hunt shared helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface PhPost {
|
|
6
|
+
rank: number;
|
|
7
|
+
name: string;
|
|
8
|
+
tagline: string;
|
|
9
|
+
author: string;
|
|
10
|
+
date: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProductHuntVoteCandidate {
|
|
15
|
+
text: string;
|
|
16
|
+
tagName?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
role?: string;
|
|
19
|
+
inButton?: boolean;
|
|
20
|
+
inReviewLink?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PRODUCTHUNT_CATEGORY_SLUGS = [
|
|
24
|
+
'ai-agents',
|
|
25
|
+
'ai-coding-agents',
|
|
26
|
+
'ai-code-editors',
|
|
27
|
+
'ai-chatbots',
|
|
28
|
+
'ai-workflow-automation',
|
|
29
|
+
'vibe-coding',
|
|
30
|
+
'developer-tools',
|
|
31
|
+
'productivity',
|
|
32
|
+
'design-creative',
|
|
33
|
+
'marketing-sales',
|
|
34
|
+
'no-code-platforms',
|
|
35
|
+
'llms',
|
|
36
|
+
'finance',
|
|
37
|
+
'social-community',
|
|
38
|
+
'engineering-development',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
const UA = 'Mozilla/5.0 (compatible; opencli/1.0)';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch Product Hunt Atom RSS feed.
|
|
45
|
+
* @param category Optional category slug (e.g. "ai", "developer-tools")
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchFeed(category?: string): Promise<PhPost[]> {
|
|
48
|
+
const url = category
|
|
49
|
+
? `https://www.producthunt.com/feed?category=${encodeURIComponent(category)}`
|
|
50
|
+
: 'https://www.producthunt.com/feed';
|
|
51
|
+
|
|
52
|
+
const resp = await fetch(url, { headers: { 'User-Agent': UA } });
|
|
53
|
+
if (!resp.ok) return [];
|
|
54
|
+
const xml = await resp.text();
|
|
55
|
+
return parseFeed(xml);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseFeed(xml: string): PhPost[] {
|
|
59
|
+
const posts: PhPost[] = [];
|
|
60
|
+
const entryRegex = /<entry>([\s\S]*?)<\/entry>/g;
|
|
61
|
+
let match;
|
|
62
|
+
let rank = 1;
|
|
63
|
+
|
|
64
|
+
while ((match = entryRegex.exec(xml))) {
|
|
65
|
+
const block = match[1];
|
|
66
|
+
|
|
67
|
+
const name = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
|
68
|
+
const author = block.match(/<name>([\s\S]*?)<\/name>/)?.[1]?.trim() ?? '';
|
|
69
|
+
const pubRaw = block.match(/<published>(.*?)<\/published>/)?.[1]?.trim() ?? '';
|
|
70
|
+
const date = pubRaw.slice(0, 10);
|
|
71
|
+
const link = block.match(/<link[^>]*href="([^"]+)"/)?.[1]?.trim() ?? '';
|
|
72
|
+
|
|
73
|
+
// Extract tagline from HTML content (first <p> text)
|
|
74
|
+
const contentRaw = block.match(/<content[^>]*>([\s\S]*?)<\/content>/)?.[1] ?? '';
|
|
75
|
+
const contentDecoded = contentRaw
|
|
76
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"');
|
|
77
|
+
const tagline = contentDecoded
|
|
78
|
+
.replace(/<[^>]+>/g, ' ')
|
|
79
|
+
.replace(/\s+/g, ' ')
|
|
80
|
+
.replace(/\s*Discussion\s*\|?\s*/gi, '')
|
|
81
|
+
.replace(/\s*\|?\s*Link\s*$/gi, '')
|
|
82
|
+
.trim()
|
|
83
|
+
.slice(0, 120);
|
|
84
|
+
|
|
85
|
+
if (name) {
|
|
86
|
+
posts.push({ rank: rank++, name, tagline, author, date, url: link });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return posts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function pickVoteCount(candidates: ProductHuntVoteCandidate[]): string {
|
|
93
|
+
const scored = candidates
|
|
94
|
+
.map((candidate) => {
|
|
95
|
+
const text = String(candidate.text ?? '').trim();
|
|
96
|
+
if (!/^\d+$/.test(text)) return null;
|
|
97
|
+
if (candidate.inReviewLink) return null;
|
|
98
|
+
|
|
99
|
+
const value = parseInt(text, 10);
|
|
100
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
101
|
+
|
|
102
|
+
const signal = `${candidate.tagName ?? ''} ${candidate.className ?? ''} ${candidate.role ?? ''}`.toLowerCase();
|
|
103
|
+
let score = 0;
|
|
104
|
+
if (candidate.inButton) score += 4;
|
|
105
|
+
if (signal.includes('vote') || signal.includes('upvote')) score += 3;
|
|
106
|
+
if (signal.includes('button')) score += 1;
|
|
107
|
+
return { text, score, value };
|
|
108
|
+
})
|
|
109
|
+
.filter((candidate): candidate is { text: string; score: number; value: number } => Boolean(candidate))
|
|
110
|
+
.sort((a, b) => {
|
|
111
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
112
|
+
if (b.value !== a.value) return b.value - a.value;
|
|
113
|
+
return a.text.localeCompare(b.text);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return scored[0]?.text ?? '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Format ISO date string to YYYY-MM-DD */
|
|
120
|
+
export function toDate(iso: string): string {
|
|
121
|
+
return iso.slice(0, 10);
|
|
122
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
|
|
3
6
|
|
|
4
7
|
cli({
|
|
5
8
|
site: 'twitter',
|
|
@@ -21,6 +24,7 @@ cli({
|
|
|
21
24
|
// Navigate to the tweet page for cookie context
|
|
22
25
|
await page.goto(`https://x.com/i/status/${tweetId}`);
|
|
23
26
|
await page.wait(3);
|
|
27
|
+
const queryId = await resolveTwitterQueryId(page, 'TweetResultByRestId', TWEET_RESULT_BY_REST_ID_QUERY_ID);
|
|
24
28
|
|
|
25
29
|
const result = await page.evaluate(`
|
|
26
30
|
async () => {
|
|
@@ -56,34 +60,7 @@ cli({
|
|
|
56
60
|
withArticlePlainText: true,
|
|
57
61
|
});
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
async function resolveQueryId(operationName, fallbackId) {
|
|
61
|
-
try {
|
|
62
|
-
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
63
|
-
if (ghResp.ok) {
|
|
64
|
-
const data = await ghResp.json();
|
|
65
|
-
const entry = data[operationName];
|
|
66
|
-
if (entry && entry.queryId) return entry.queryId;
|
|
67
|
-
}
|
|
68
|
-
} catch {}
|
|
69
|
-
try {
|
|
70
|
-
const scripts = performance.getEntriesByType('resource')
|
|
71
|
-
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
72
|
-
.map(r => r.name);
|
|
73
|
-
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
74
|
-
try {
|
|
75
|
-
const text = await (await fetch(scriptUrl)).text();
|
|
76
|
-
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
77
|
-
const m = text.match(re);
|
|
78
|
-
if (m) return m[1];
|
|
79
|
-
} catch {}
|
|
80
|
-
}
|
|
81
|
-
} catch {}
|
|
82
|
-
return fallbackId;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const queryId = await resolveQueryId('TweetResultByRestId', '7xflPyRiUxGVbJd4uWmbfg');
|
|
86
|
-
const url = '/i/api/graphql/' + queryId + '/TweetResultByRestId?variables='
|
|
63
|
+
const url = '/i/api/graphql/' + ${JSON.stringify(queryId)} + '/TweetResultByRestId?variables='
|
|
87
64
|
+ encodeURIComponent(variables)
|
|
88
65
|
+ '&features=' + encodeURIComponent(features)
|
|
89
66
|
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './likes.js';
|
|
3
|
+
|
|
4
|
+
describe('twitter likes helpers', () => {
|
|
5
|
+
it('falls back when queryId contains unsafe characters', () => {
|
|
6
|
+
expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
|
|
7
|
+
expect(__test__.sanitizeQueryId('bad"id', 'fallback')).toBe('fallback');
|
|
8
|
+
expect(__test__.sanitizeQueryId('bad/id', 'fallback')).toBe('fallback');
|
|
9
|
+
expect(__test__.sanitizeQueryId(null, 'fallback')).toBe('fallback');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('builds likes url with the provided queryId', () => {
|
|
13
|
+
const url = __test__.buildLikesUrl('query123', '42', 20, 'cursor-1');
|
|
14
|
+
|
|
15
|
+
expect(url).toContain('/i/api/graphql/query123/Likes');
|
|
16
|
+
expect(decodeURIComponent(url)).toContain('"userId":"42"');
|
|
17
|
+
expect(decodeURIComponent(url)).toContain('"cursor":"cursor-1"');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses likes timeline entries and bottom cursor', () => {
|
|
21
|
+
const payload = {
|
|
22
|
+
data: {
|
|
23
|
+
user: {
|
|
24
|
+
result: {
|
|
25
|
+
timeline_v2: {
|
|
26
|
+
timeline: {
|
|
27
|
+
instructions: [
|
|
28
|
+
{
|
|
29
|
+
entries: [
|
|
30
|
+
{
|
|
31
|
+
entryId: 'tweet-1',
|
|
32
|
+
content: {
|
|
33
|
+
itemContent: {
|
|
34
|
+
tweet_results: {
|
|
35
|
+
result: {
|
|
36
|
+
rest_id: '1',
|
|
37
|
+
legacy: {
|
|
38
|
+
full_text: 'liked post',
|
|
39
|
+
favorite_count: 7,
|
|
40
|
+
retweet_count: 2,
|
|
41
|
+
created_at: 'now',
|
|
42
|
+
},
|
|
43
|
+
core: {
|
|
44
|
+
user_results: {
|
|
45
|
+
result: {
|
|
46
|
+
legacy: {
|
|
47
|
+
screen_name: 'alice',
|
|
48
|
+
name: 'Alice',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
entryId: 'cursor-bottom-1',
|
|
60
|
+
content: {
|
|
61
|
+
entryType: 'TimelineTimelineCursor',
|
|
62
|
+
cursorType: 'Bottom',
|
|
63
|
+
value: 'cursor-next',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const result = __test__.parseLikes(payload, new Set());
|
|
77
|
+
|
|
78
|
+
expect(result.nextCursor).toBe('cursor-next');
|
|
79
|
+
expect(result.tweets).toHaveLength(1);
|
|
80
|
+
expect(result.tweets[0]).toMatchObject({
|
|
81
|
+
id: '1',
|
|
82
|
+
author: 'alice',
|
|
83
|
+
name: 'Alice',
|
|
84
|
+
text: 'liked post',
|
|
85
|
+
likes: 7,
|
|
86
|
+
retweets: 2,
|
|
87
|
+
created_at: 'now',
|
|
88
|
+
url: 'https://x.com/alice/status/1',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
3
|
+
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
|
+
const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
|
|
7
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
8
|
+
|
|
9
|
+
const FEATURES = {
|
|
10
|
+
rweb_video_screen_enabled: false,
|
|
11
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
12
|
+
responsive_web_profile_redirect_enabled: false,
|
|
13
|
+
rweb_tipjar_consumption_enabled: false,
|
|
14
|
+
verified_phone_label_enabled: false,
|
|
15
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
16
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
17
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
18
|
+
premium_content_api_read_enabled: false,
|
|
19
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
20
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
21
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
22
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
23
|
+
responsive_web_jetfuel_frame: true,
|
|
24
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
25
|
+
responsive_web_grok_annotations_enabled: true,
|
|
26
|
+
articles_preview_enabled: true,
|
|
27
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
28
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
29
|
+
view_counts_everywhere_api_enabled: true,
|
|
30
|
+
longform_notetweets_consumption_enabled: true,
|
|
31
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
32
|
+
tweet_awards_web_tipping_enabled: false,
|
|
33
|
+
content_disclosure_indicator_enabled: true,
|
|
34
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
35
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
36
|
+
responsive_web_grok_analysis_button_from_backend: true,
|
|
37
|
+
post_ctas_fetch_enabled: false,
|
|
38
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
39
|
+
standardized_nudges_misinfo: true,
|
|
40
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
41
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
42
|
+
longform_notetweets_inline_media_enabled: false,
|
|
43
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
44
|
+
responsive_web_grok_imagine_annotation_enabled: true,
|
|
45
|
+
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
|
46
|
+
responsive_web_enhance_cards_enabled: false
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
interface LikedTweet {
|
|
50
|
+
id: string;
|
|
51
|
+
author: string;
|
|
52
|
+
name: string;
|
|
53
|
+
text: string;
|
|
54
|
+
likes: number;
|
|
55
|
+
retweets: number;
|
|
56
|
+
created_at: string;
|
|
57
|
+
url: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildLikesUrl(queryId: string, userId: string, count: number, cursor?: string | null): string {
|
|
61
|
+
const vars: Record<string, any> = {
|
|
62
|
+
userId,
|
|
63
|
+
count,
|
|
64
|
+
includePromotedContent: false,
|
|
65
|
+
withClientEventToken: false,
|
|
66
|
+
withBirdwatchNotes: false,
|
|
67
|
+
withVoice: true
|
|
68
|
+
};
|
|
69
|
+
if (cursor) vars.cursor = cursor;
|
|
70
|
+
|
|
71
|
+
return `/i/api/graphql/${queryId}/Likes`
|
|
72
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
73
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildUserByScreenNameUrl(queryId: string, screenName: string): string {
|
|
77
|
+
const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
|
|
78
|
+
const feats = JSON.stringify({
|
|
79
|
+
hidden_profile_subscriptions_enabled: true,
|
|
80
|
+
rweb_tipjar_consumption_enabled: true,
|
|
81
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
82
|
+
verified_phone_label_enabled: false,
|
|
83
|
+
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
84
|
+
subscriptions_verification_info_verified_since_enabled: true,
|
|
85
|
+
highlights_tweets_tab_ui_enabled: true,
|
|
86
|
+
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
87
|
+
subscriptions_feature_can_gift_premium: true,
|
|
88
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
89
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
90
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return `/i/api/graphql/${queryId}/UserByScreenName`
|
|
94
|
+
+ `?variables=${encodeURIComponent(vars)}`
|
|
95
|
+
+ `&features=${encodeURIComponent(feats)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractLikedTweet(result: any, seen: Set<string>): LikedTweet | null {
|
|
99
|
+
if (!result) return null;
|
|
100
|
+
const tw = result.tweet || result;
|
|
101
|
+
const legacy = tw.legacy || {};
|
|
102
|
+
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
|
103
|
+
seen.add(tw.rest_id);
|
|
104
|
+
|
|
105
|
+
const user = tw.core?.user_results?.result;
|
|
106
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
|
107
|
+
const displayName = user?.legacy?.name || user?.core?.name || '';
|
|
108
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: tw.rest_id,
|
|
112
|
+
author: screenName,
|
|
113
|
+
name: displayName,
|
|
114
|
+
text: noteText || legacy.full_text || '',
|
|
115
|
+
likes: legacy.favorite_count || 0,
|
|
116
|
+
retweets: legacy.retweet_count || 0,
|
|
117
|
+
created_at: legacy.created_at || '',
|
|
118
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseLikes(data: any, seen: Set<string>): { tweets: LikedTweet[]; nextCursor: string | null } {
|
|
123
|
+
const tweets: LikedTweet[] = [];
|
|
124
|
+
let nextCursor: string | null = null;
|
|
125
|
+
|
|
126
|
+
const instructions =
|
|
127
|
+
data?.data?.user?.result?.timeline_v2?.timeline?.instructions
|
|
128
|
+
|| data?.data?.user?.result?.timeline?.timeline?.instructions
|
|
129
|
+
|| [];
|
|
130
|
+
|
|
131
|
+
for (const inst of instructions) {
|
|
132
|
+
for (const entry of inst.entries || []) {
|
|
133
|
+
const content = entry.content;
|
|
134
|
+
|
|
135
|
+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
|
136
|
+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') nextCursor = content.value;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
140
|
+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const direct = extractLikedTweet(content?.itemContent?.tweet_results?.result, seen);
|
|
145
|
+
if (direct) {
|
|
146
|
+
tweets.push(direct);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const item of content?.items || []) {
|
|
151
|
+
const nested = extractLikedTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
152
|
+
if (nested) tweets.push(nested);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { tweets, nextCursor };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
cli({
|
|
161
|
+
site: 'twitter',
|
|
162
|
+
name: 'likes',
|
|
163
|
+
description: 'Fetch liked tweets of a Twitter user',
|
|
164
|
+
domain: 'x.com',
|
|
165
|
+
strategy: Strategy.COOKIE,
|
|
166
|
+
browser: true,
|
|
167
|
+
args: [
|
|
168
|
+
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
|
169
|
+
{ name: 'limit', type: 'int', default: 20 },
|
|
170
|
+
],
|
|
171
|
+
columns: ['author', 'name', 'text', 'likes', 'url'],
|
|
172
|
+
func: async (page, kwargs) => {
|
|
173
|
+
const limit = kwargs.limit || 20;
|
|
174
|
+
let username = (kwargs.username || '').replace(/^@/, '');
|
|
175
|
+
|
|
176
|
+
await page.goto('https://x.com');
|
|
177
|
+
await page.wait(3);
|
|
178
|
+
|
|
179
|
+
const ct0 = await page.evaluate(`() => {
|
|
180
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
181
|
+
}`);
|
|
182
|
+
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
183
|
+
|
|
184
|
+
// If no username provided, detect the logged-in user
|
|
185
|
+
if (!username) {
|
|
186
|
+
const href = await page.evaluate(`() => {
|
|
187
|
+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
188
|
+
return link ? link.getAttribute('href') : null;
|
|
189
|
+
}`);
|
|
190
|
+
if (!href) throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
|
191
|
+
username = href.replace('/', '');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
|
|
195
|
+
const userByScreenNameQueryId = await resolveTwitterQueryId(
|
|
196
|
+
page,
|
|
197
|
+
'UserByScreenName',
|
|
198
|
+
USER_BY_SCREEN_NAME_QUERY_ID,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const headers = JSON.stringify({
|
|
202
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
203
|
+
'X-Csrf-Token': ct0,
|
|
204
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
205
|
+
'X-Twitter-Active-User': 'yes',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Get userId from screen_name
|
|
209
|
+
const userId = await page.evaluate(`async () => {
|
|
210
|
+
const screenName = ${JSON.stringify(username)};
|
|
211
|
+
const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, username))};
|
|
212
|
+
const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
|
|
213
|
+
if (!resp.ok) return null;
|
|
214
|
+
const d = await resp.json();
|
|
215
|
+
return d.data?.user?.result?.rest_id || null;
|
|
216
|
+
}`);
|
|
217
|
+
|
|
218
|
+
if (!userId) {
|
|
219
|
+
throw new CommandExecutionError(`Could not find user @${username}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const allTweets: LikedTweet[] = [];
|
|
223
|
+
const seen = new Set<string>();
|
|
224
|
+
let cursor: string | null = null;
|
|
225
|
+
|
|
226
|
+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
|
227
|
+
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
228
|
+
const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor);
|
|
229
|
+
|
|
230
|
+
const data = await page.evaluate(`async () => {
|
|
231
|
+
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
232
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
233
|
+
}`);
|
|
234
|
+
|
|
235
|
+
if (data?.error) {
|
|
236
|
+
if (allTweets.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { tweets, nextCursor } = parseLikes(data, seen);
|
|
241
|
+
allTweets.push(...tweets);
|
|
242
|
+
|
|
243
|
+
if (!nextCursor || nextCursor === cursor) break;
|
|
244
|
+
cursor = nextCursor;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return allTweets.slice(0, limit);
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const __test__ = {
|
|
252
|
+
sanitizeQueryId,
|
|
253
|
+
buildLikesUrl,
|
|
254
|
+
buildUserByScreenNameUrl,
|
|
255
|
+
parseLikes,
|
|
256
|
+
};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
3
6
|
|
|
4
7
|
cli({
|
|
5
8
|
site: 'twitter',
|
|
@@ -30,6 +33,7 @@ cli({
|
|
|
30
33
|
// Navigate directly to the user's profile page (gives us cookie context)
|
|
31
34
|
await page.goto(`https://x.com/${username}`);
|
|
32
35
|
await page.wait(3);
|
|
36
|
+
const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
33
37
|
|
|
34
38
|
const result = await page.evaluate(`
|
|
35
39
|
async () => {
|
|
@@ -64,34 +68,7 @@ cli({
|
|
|
64
68
|
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
65
69
|
});
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
async function resolveQueryId(operationName, fallbackId) {
|
|
69
|
-
try {
|
|
70
|
-
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
71
|
-
if (ghResp.ok) {
|
|
72
|
-
const data = await ghResp.json();
|
|
73
|
-
const entry = data[operationName];
|
|
74
|
-
if (entry && entry.queryId) return entry.queryId;
|
|
75
|
-
}
|
|
76
|
-
} catch {}
|
|
77
|
-
try {
|
|
78
|
-
const scripts = performance.getEntriesByType('resource')
|
|
79
|
-
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
80
|
-
.map(r => r.name);
|
|
81
|
-
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
82
|
-
try {
|
|
83
|
-
const text = await (await fetch(scriptUrl)).text();
|
|
84
|
-
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
85
|
-
const m = text.match(re);
|
|
86
|
-
if (m) return m[1];
|
|
87
|
-
} catch {}
|
|
88
|
-
}
|
|
89
|
-
} catch {}
|
|
90
|
-
return fallbackId;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const queryId = await resolveQueryId('UserByScreenName', 'qRednkZG-rn1P6b48NINmQ');
|
|
94
|
-
const url = '/i/api/graphql/' + queryId + '/UserByScreenName?variables='
|
|
71
|
+
const url = '/i/api/graphql/' + ${JSON.stringify(queryId)} + '/UserByScreenName?variables='
|
|
95
72
|
+ encodeURIComponent(variables)
|
|
96
73
|
+ '&features=' + encodeURIComponent(features);
|
|
97
74
|
|
|
@@ -39,6 +39,7 @@ describe('twitter search command', () => {
|
|
|
39
39
|
legacy: {
|
|
40
40
|
full_text: 'hello world',
|
|
41
41
|
favorite_count: 7,
|
|
42
|
+
created_at: 'Thu Mar 26 10:30:00 +0000 2026',
|
|
42
43
|
},
|
|
43
44
|
core: {
|
|
44
45
|
user_results: {
|
|
@@ -75,6 +76,7 @@ describe('twitter search command', () => {
|
|
|
75
76
|
id: '1',
|
|
76
77
|
author: 'alice',
|
|
77
78
|
text: 'hello world',
|
|
79
|
+
created_at: 'Thu Mar 26 10:30:00 +0000 2026',
|
|
78
80
|
likes: 7,
|
|
79
81
|
views: '12',
|
|
80
82
|
url: 'https://x.com/i/status/1',
|
|
@@ -49,7 +49,7 @@ cli({
|
|
|
49
49
|
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'] },
|
|
50
50
|
{ name: 'limit', type: 'int', default: 15 },
|
|
51
51
|
],
|
|
52
|
-
columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
|
|
52
|
+
columns: ['id', 'author', 'text', 'created_at', 'likes', 'views', 'url'],
|
|
53
53
|
func: async (page, kwargs) => {
|
|
54
54
|
const query = kwargs.query;
|
|
55
55
|
const filter = kwargs.filter === 'live' ? 'live' : 'top';
|
|
@@ -101,10 +101,12 @@ cli({
|
|
|
101
101
|
|
|
102
102
|
// Twitter moved screen_name from legacy to core
|
|
103
103
|
const tweetUser = tweet.core?.user_results?.result;
|
|
104
|
+
|
|
104
105
|
results.push({
|
|
105
106
|
id: tweet.rest_id,
|
|
106
107
|
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
|
|
107
108
|
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
|
|
109
|
+
created_at: tweet.legacy?.created_at || '',
|
|
108
110
|
likes: tweet.legacy?.favorite_count || 0,
|
|
109
111
|
views: tweet.views?.count || '0',
|
|
110
112
|
url: `https://x.com/i/status/${tweet.rest_id}`
|