browser-web-search 0.1.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.
Files changed (46) hide show
  1. package/README.md +132 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +691 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +45 -0
  6. package/sites/36kr/newsflash.js +76 -0
  7. package/sites/baidu/search.js +62 -0
  8. package/sites/bilibili/comments.js +74 -0
  9. package/sites/bilibili/feed.js +79 -0
  10. package/sites/bilibili/history.js +43 -0
  11. package/sites/bilibili/me.js +45 -0
  12. package/sites/bilibili/popular.js +44 -0
  13. package/sites/bilibili/ranking.js +42 -0
  14. package/sites/bilibili/search.js +46 -0
  15. package/sites/bilibili/trending.js +32 -0
  16. package/sites/bilibili/video.js +73 -0
  17. package/sites/bing/search.js +40 -0
  18. package/sites/boss/detail.js +38 -0
  19. package/sites/boss/search.js +44 -0
  20. package/sites/cnblogs/search.js +68 -0
  21. package/sites/csdn/search.js +51 -0
  22. package/sites/douban/comments.js +58 -0
  23. package/sites/douban/movie-hot.js +64 -0
  24. package/sites/douban/movie-top.js +65 -0
  25. package/sites/douban/movie.js +117 -0
  26. package/sites/douban/search.js +90 -0
  27. package/sites/douban/top250.js +73 -0
  28. package/sites/github/fork.js +38 -0
  29. package/sites/github/issue-create.js +42 -0
  30. package/sites/github/issues.js +32 -0
  31. package/sites/github/me.js +22 -0
  32. package/sites/github/pr-create.js +55 -0
  33. package/sites/github/repo.js +27 -0
  34. package/sites/google/search.js +54 -0
  35. package/sites/toutiao/hot.js +107 -0
  36. package/sites/toutiao/search.js +146 -0
  37. package/sites/xiaohongshu/comments.js +56 -0
  38. package/sites/xiaohongshu/feed.js +50 -0
  39. package/sites/xiaohongshu/me.js +49 -0
  40. package/sites/xiaohongshu/note.js +63 -0
  41. package/sites/xiaohongshu/search.js +56 -0
  42. package/sites/xiaohongshu/user_posts.js +53 -0
  43. package/sites/zhihu/hot.js +36 -0
  44. package/sites/zhihu/me.js +43 -0
  45. package/sites/zhihu/question.js +62 -0
  46. package/sites/zhihu/search.js +58 -0
@@ -0,0 +1,117 @@
1
+ /* @meta
2
+ {
3
+ "name": "douban/movie",
4
+ "description": "Get detailed movie/TV info with rating, cast, and hot reviews from Douban",
5
+ "domain": "movie.douban.com",
6
+ "args": {
7
+ "id": {"required": true, "description": "Douban subject ID (e.g. 1292052 for The Shawshank Redemption)"}
8
+ },
9
+ "capabilities": ["network"],
10
+ "readOnly": true,
11
+ "example": "bb-browser site douban/movie 1292052"
12
+ }
13
+ */
14
+
15
+ async function(args) {
16
+ if (!args.id) return {error: 'Missing argument: id'};
17
+ const id = String(args.id).trim();
18
+
19
+ // Fetch structured data from the JSON API
20
+ const apiResp = await fetch('https://movie.douban.com/j/subject_abstract?subject_id=' + id, {credentials: 'include'});
21
+ if (!apiResp.ok) return {error: 'HTTP ' + apiResp.status, hint: 'Not logged in or invalid ID?'};
22
+ const apiData = await apiResp.json();
23
+ if (apiData.r !== 0 || !apiData.subject) return {error: 'Subject not found', hint: 'Check the ID'};
24
+
25
+ const s = apiData.subject;
26
+
27
+ // Also fetch the HTML page for richer data (summary, rating distribution, hot comments)
28
+ const pageResp = await fetch('https://movie.douban.com/subject/' + id + '/', {credentials: 'include'});
29
+ let summary = '', ratingDist = {}, hotComments = [], recommendations = [], votes = null, info = '';
30
+
31
+ if (pageResp.ok) {
32
+ const html = await pageResp.text();
33
+ const doc = new DOMParser().parseFromString(html, 'text/html');
34
+
35
+ // Summary
36
+ const summaryEl = doc.querySelector('[property="v:summary"]');
37
+ summary = summaryEl ? summaryEl.textContent.trim() : '';
38
+
39
+ // Vote count
40
+ const votesEl = doc.querySelector('[property="v:votes"]');
41
+ votes = votesEl ? parseInt(votesEl.textContent) : null;
42
+
43
+ // Info block
44
+ const infoEl = doc.querySelector('#info');
45
+ info = infoEl ? infoEl.innerText || infoEl.textContent.trim() : '';
46
+
47
+ // Rating distribution
48
+ doc.querySelectorAll('.ratings-on-weight .item').forEach(function(el) {
49
+ var star = el.querySelector('span:first-child');
50
+ var pct = el.querySelector('.rating_per');
51
+ if (star && pct) ratingDist[star.textContent.trim()] = pct.textContent.trim();
52
+ });
53
+
54
+ // Hot comments
55
+ doc.querySelectorAll('#hot-comments .comment-item').forEach(function(el) {
56
+ var author = el.querySelector('.comment-info a');
57
+ var rating = el.querySelector('.comment-info .rating');
58
+ var content = el.querySelector('.short');
59
+ var voteCount = el.querySelector('.vote-count');
60
+ var date = el.querySelector('.comment-time');
61
+ hotComments.push({
62
+ author: author ? author.textContent.trim() : '',
63
+ rating: rating ? rating.title : '',
64
+ content: content ? content.textContent.trim() : '',
65
+ votes: voteCount ? parseInt(voteCount.textContent) || 0 : 0,
66
+ date: date ? date.textContent.trim() : ''
67
+ });
68
+ });
69
+
70
+ // Recommendations
71
+ doc.querySelectorAll('.recommendations-bd dl').forEach(function(dl) {
72
+ var a = dl.querySelector('dd a');
73
+ if (a) {
74
+ var recId = a.href?.match(/subject\/(\d+)/)?.[1];
75
+ recommendations.push({title: a.textContent.trim(), id: recId, url: a.href});
76
+ }
77
+ });
78
+ }
79
+
80
+ // Parse info block for structured fields
81
+ const parseInfo = function(text) {
82
+ const result = {};
83
+ const lines = text.split('\n').map(function(l) { return l.trim(); }).filter(Boolean);
84
+ lines.forEach(function(line) {
85
+ var m = line.match(/^(.+?):\s*(.+)$/);
86
+ if (m) result[m[1].trim()] = m[2].trim();
87
+ });
88
+ return result;
89
+ };
90
+ const infoFields = parseInfo(info);
91
+
92
+ return {
93
+ id: s.id,
94
+ title: s.title,
95
+ subtype: s.subtype,
96
+ is_tv: s.is_tv,
97
+ rating: parseFloat(s.rate) || null,
98
+ votes: votes,
99
+ rating_distribution: ratingDist,
100
+ directors: s.directors,
101
+ actors: s.actors,
102
+ types: s.types,
103
+ region: s.region,
104
+ duration: s.duration,
105
+ release_year: s.release_year,
106
+ episodes_count: s.episodes_count || null,
107
+ imdb: infoFields['IMDb'] || null,
108
+ alias: infoFields['又名'] || null,
109
+ language: infoFields['语言'] || null,
110
+ release_date: infoFields['上映日期'] || infoFields['首播'] || null,
111
+ summary: summary,
112
+ playable: s.playable,
113
+ url: s.url,
114
+ hot_comments: hotComments,
115
+ recommendations: recommendations
116
+ };
117
+ }
@@ -0,0 +1,90 @@
1
+ /* @meta
2
+ {
3
+ "name": "douban/search",
4
+ "description": "Search Douban across movies, books, and music",
5
+ "domain": "www.douban.com",
6
+ "args": {
7
+ "keyword": {"required": true, "description": "Search keyword (Chinese or English)"}
8
+ },
9
+ "capabilities": ["network"],
10
+ "readOnly": true,
11
+ "example": "bb-browser site douban/search 三体"
12
+ }
13
+ */
14
+
15
+ async function(args) {
16
+ if (!args.keyword) return {error: 'Missing argument: keyword'};
17
+ const q = encodeURIComponent(args.keyword);
18
+
19
+ // Try the rich search_suggest endpoint (requires www.douban.com origin)
20
+ var resp;
21
+ var usedFallback = false;
22
+ try {
23
+ resp = await fetch('https://www.douban.com/j/search_suggest?q=' + q, {credentials: 'include'});
24
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
25
+ } catch (e) {
26
+ // Fallback: use movie.douban.com subject_suggest (works cross-subdomain via same eTLD+1 cookies)
27
+ try {
28
+ resp = await fetch('/j/subject_suggest?q=' + q, {credentials: 'include'});
29
+ usedFallback = true;
30
+ } catch (e2) {
31
+ return {error: 'Search failed: ' + e2.message, hint: 'Not logged in? Navigate to www.douban.com first.'};
32
+ }
33
+ }
34
+
35
+ if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Not logged in?'};
36
+ var d = await resp.json();
37
+
38
+ if (usedFallback) {
39
+ // subject_suggest returns an array directly
40
+ var items = (Array.isArray(d) ? d : []).map(function(c, i) {
41
+ return {
42
+ rank: i + 1,
43
+ id: c.id,
44
+ type: c.type === 'movie' ? 'movie' : c.type === 'b' ? 'book' : c.type || 'unknown',
45
+ title: c.title,
46
+ subtitle: c.sub_title || '',
47
+ rating: null,
48
+ info: '',
49
+ year: c.year || null,
50
+ cover: c.img || c.pic || null,
51
+ url: c.url
52
+ };
53
+ });
54
+ return {
55
+ keyword: args.keyword,
56
+ count: items.length,
57
+ results: items,
58
+ suggestions: [],
59
+ note: 'Limited results (movie/book only). For richer results, navigate to www.douban.com first.'
60
+ };
61
+ }
62
+
63
+ // Rich search_suggest response with cards
64
+ var cards = (d.cards || []).map(function(c, i) {
65
+ var id = c.url && c.url.match(/subject\/(\d+)/);
66
+ id = id ? id[1] : null;
67
+ var ratingMatch = c.card_subtitle && c.card_subtitle.match(/([\d.]+)分/);
68
+ return {
69
+ rank: i + 1,
70
+ id: id,
71
+ type: c.type || 'unknown',
72
+ title: c.title,
73
+ subtitle: c.abstract || '',
74
+ rating: ratingMatch ? parseFloat(ratingMatch[1]) : null,
75
+ info: c.card_subtitle || '',
76
+ year: c.year || null,
77
+ cover: c.cover_url || null,
78
+ url: c.url
79
+ };
80
+ });
81
+
82
+ var suggestions = d.words || [];
83
+
84
+ return {
85
+ keyword: args.keyword,
86
+ count: cards.length,
87
+ results: cards,
88
+ suggestions: suggestions
89
+ };
90
+ }
@@ -0,0 +1,73 @@
1
+ /* @meta
2
+ {
3
+ "name": "douban/top250",
4
+ "description": "Get Douban Top 250 movies list",
5
+ "domain": "movie.douban.com",
6
+ "args": {
7
+ "start": {"required": false, "description": "Start position (default: 0, step by 25). Use 0 for #1-25, 25 for #26-50, etc."}
8
+ },
9
+ "capabilities": ["network"],
10
+ "readOnly": true,
11
+ "example": "bb-browser site douban/top250"
12
+ }
13
+ */
14
+
15
+ async function(args) {
16
+ const start = parseInt(args.start) || 0;
17
+
18
+ const resp = await fetch('https://movie.douban.com/top250?start=' + start, {credentials: 'include'});
19
+ if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Not logged in?'};
20
+ const html = await resp.text();
21
+ const doc = new DOMParser().parseFromString(html, 'text/html');
22
+
23
+ const items = [];
24
+ doc.querySelectorAll('.grid_view .item').forEach(function(el) {
25
+ var rank = el.querySelector('.pic em');
26
+ var titleEl = el.querySelector('.hd a .title');
27
+ var otherTitleEl = el.querySelector('.hd a .other');
28
+ var ratingEl = el.querySelector('.rating_num');
29
+ var link = el.querySelector('.hd a');
30
+ var quoteEl = el.querySelector('.quote .inq') || el.querySelector('.quote span');
31
+ var infoEl = el.querySelector('.bd p');
32
+
33
+ // Vote count is in a span like "3268455人评价"
34
+ var voteSpans = el.querySelectorAll('.bd div span');
35
+ var votes = null;
36
+ voteSpans.forEach(function(sp) {
37
+ var m = sp.textContent.match(/(\d+)人评价/);
38
+ if (m) votes = parseInt(m[1]);
39
+ });
40
+
41
+ var id = link?.href?.match(/subject\/(\d+)/)?.[1];
42
+
43
+ // Parse info line for director, year, region, genre
44
+ var infoText = infoEl ? infoEl.textContent.trim() : '';
45
+ var lines = infoText.split('\n').map(function(l) { return l.trim(); }).filter(Boolean);
46
+ var directorLine = lines[0] || '';
47
+ var metaLine = lines[1] || '';
48
+ var metaParts = metaLine.split('/').map(function(p) { return p.trim(); });
49
+
50
+ items.push({
51
+ rank: rank ? parseInt(rank.textContent) : null,
52
+ id: id,
53
+ title: titleEl ? titleEl.textContent.trim() : '',
54
+ other_title: otherTitleEl ? otherTitleEl.textContent.trim().replace(/^\s*\/\s*/, '') : '',
55
+ rating: ratingEl ? parseFloat(ratingEl.textContent) : null,
56
+ votes: votes,
57
+ quote: quoteEl ? quoteEl.textContent.trim() : '',
58
+ year: metaParts[0] || '',
59
+ region: metaParts[1] || '',
60
+ genre: metaParts[2] || '',
61
+ url: link ? link.href : ''
62
+ });
63
+ });
64
+
65
+ return {
66
+ start: start,
67
+ count: items.length,
68
+ total: 250,
69
+ has_more: start + items.length < 250,
70
+ next_start: start + items.length < 250 ? start + 25 : null,
71
+ items: items
72
+ };
73
+ }
@@ -0,0 +1,38 @@
1
+ /* @meta
2
+ {
3
+ "name": "github/fork",
4
+ "description": "Fork a GitHub repository",
5
+ "domain": "github.com",
6
+ "args": {
7
+ "repo": {"required": true, "description": "Repository to fork (owner/repo)"}
8
+ },
9
+ "capabilities": ["network"],
10
+ "readOnly": false,
11
+ "example": "bb-browser site github/fork epiral/bb-sites"
12
+ }
13
+ */
14
+
15
+ async function(args) {
16
+ if (!args.repo) return {error: 'Missing argument: repo'};
17
+
18
+ const resp = await fetch('https://api.github.com/repos/' + args.repo + '/forks', {
19
+ method: 'POST',
20
+ credentials: 'include',
21
+ headers: {'Content-Type': 'application/json'},
22
+ body: JSON.stringify({})
23
+ });
24
+
25
+ if (!resp.ok) {
26
+ const status = resp.status;
27
+ if (status === 401 || status === 403) return {error: 'HTTP ' + status, hint: 'Not logged in to GitHub'};
28
+ if (status === 404) return {error: 'Repo not found: ' + args.repo};
29
+ return {error: 'HTTP ' + status};
30
+ }
31
+
32
+ const fork = await resp.json();
33
+ return {
34
+ full_name: fork.full_name,
35
+ url: fork.html_url,
36
+ clone_url: fork.clone_url
37
+ };
38
+ }
@@ -0,0 +1,42 @@
1
+ /* @meta
2
+ {
3
+ "name": "github/issue-create",
4
+ "description": "Create a GitHub issue",
5
+ "domain": "github.com",
6
+ "args": {
7
+ "repo": {"required": true, "description": "owner/repo format"},
8
+ "title": {"required": true, "description": "Issue title"},
9
+ "body": {"required": false, "description": "Issue body (markdown)"}
10
+ },
11
+ "capabilities": ["network"],
12
+ "readOnly": false,
13
+ "example": "bb-browser site github/issue-create epiral/bb-sites --title \"[reddit/me] returns empty\" --body \"Description here\""
14
+ }
15
+ */
16
+
17
+ async function(args) {
18
+ if (!args.repo) return {error: 'Missing argument: repo'};
19
+ if (!args.title) return {error: 'Missing argument: title'};
20
+
21
+ const resp = await fetch('https://api.github.com/repos/' + args.repo + '/issues', {
22
+ method: 'POST',
23
+ credentials: 'include',
24
+ headers: {'Content-Type': 'application/json'},
25
+ body: JSON.stringify({title: args.title, body: args.body || ''})
26
+ });
27
+
28
+ if (!resp.ok) {
29
+ const status = resp.status;
30
+ if (status === 401 || status === 403) return {error: 'HTTP ' + status, hint: 'Not logged in to GitHub'};
31
+ if (status === 404) return {error: 'Repo not found: ' + args.repo};
32
+ return {error: 'HTTP ' + status};
33
+ }
34
+
35
+ const issue = await resp.json();
36
+ return {
37
+ number: issue.number,
38
+ title: issue.title,
39
+ url: issue.html_url,
40
+ state: issue.state
41
+ };
42
+ }
@@ -0,0 +1,32 @@
1
+ /* @meta
2
+ {
3
+ "name": "github/issues",
4
+ "description": "获取 GitHub 仓库的 issue 列表",
5
+ "domain": "github.com",
6
+ "args": {
7
+ "repo": {"required": true, "description": "owner/repo format"},
8
+ "state": {"required": false, "description": "open, closed, or all (default: open)"}
9
+ },
10
+ "capabilities": ["network"],
11
+ "readOnly": true,
12
+ "example": "bb-browser site github/issues epiral/bb-browser"
13
+ }
14
+ */
15
+
16
+ async function(args) {
17
+ if (!args.repo) return {error: 'Missing argument: repo'};
18
+ const state = args.state || 'open';
19
+ const resp = await fetch('https://api.github.com/repos/' + args.repo + '/issues?state=' + state + '&per_page=30', {credentials: 'include'});
20
+ if (!resp.ok) return {error: 'HTTP ' + resp.status};
21
+ const issues = await resp.json();
22
+ return {
23
+ repo: args.repo, state, count: issues.length,
24
+ issues: issues.map(i => ({
25
+ number: i.number, title: i.title, state: i.state,
26
+ url: i.html_url,
27
+ author: i.user?.login, labels: i.labels?.map(l => l.name),
28
+ comments: i.comments, created_at: i.created_at,
29
+ is_pr: !!i.pull_request
30
+ }))
31
+ };
32
+ }
@@ -0,0 +1,22 @@
1
+ /* @meta
2
+ {
3
+ "name": "github/me",
4
+ "description": "获取当前 GitHub 登录用户信息",
5
+ "domain": "github.com",
6
+ "args": {},
7
+ "capabilities": ["network"],
8
+ "readOnly": true
9
+ }
10
+ */
11
+
12
+ async function(args) {
13
+ const resp = await fetch('https://api.github.com/user', {credentials: 'include'});
14
+ if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: resp.status === 401 ? 'Not logged into github.com' : 'API error'};
15
+ const d = await resp.json();
16
+ return {
17
+ login: d.login, name: d.name, bio: d.bio,
18
+ url: d.html_url || ('https://github.com/' + d.login),
19
+ public_repos: d.public_repos, followers: d.followers, following: d.following,
20
+ created_at: d.created_at
21
+ };
22
+ }
@@ -0,0 +1,55 @@
1
+ /* @meta
2
+ {
3
+ "name": "github/pr-create",
4
+ "description": "Create a GitHub pull request",
5
+ "domain": "github.com",
6
+ "args": {
7
+ "repo": {"required": true, "description": "Target repo (owner/repo)"},
8
+ "title": {"required": true, "description": "PR title"},
9
+ "head": {"required": true, "description": "Source branch (user:branch or branch)"},
10
+ "base": {"required": false, "description": "Target branch (default: main)"},
11
+ "body": {"required": false, "description": "PR description (markdown)"}
12
+ },
13
+ "capabilities": ["network"],
14
+ "readOnly": false,
15
+ "example": "bb-browser site github/pr-create epiral/bb-sites --title \"feat(weibo): add hot adapter\" --head myuser:feat-weibo --body \"Adds weibo/hot.js\""
16
+ }
17
+ */
18
+
19
+ async function(args) {
20
+ if (!args.repo) return {error: 'Missing argument: repo'};
21
+ if (!args.title) return {error: 'Missing argument: title'};
22
+ if (!args.head) return {error: 'Missing argument: head', hint: 'Provide source branch as "user:branch" or "branch"'};
23
+
24
+ const resp = await fetch('https://api.github.com/repos/' + args.repo + '/pulls', {
25
+ method: 'POST',
26
+ credentials: 'include',
27
+ headers: {'Content-Type': 'application/json'},
28
+ body: JSON.stringify({
29
+ title: args.title,
30
+ head: args.head,
31
+ base: args.base || 'main',
32
+ body: args.body || ''
33
+ })
34
+ });
35
+
36
+ if (!resp.ok) {
37
+ const status = resp.status;
38
+ if (status === 401 || status === 403) return {error: 'HTTP ' + status, hint: 'Not logged in to GitHub'};
39
+ if (status === 404) return {error: 'Repo not found: ' + args.repo};
40
+ if (status === 422) {
41
+ const d = await resp.json().catch(() => null);
42
+ const msg = d?.errors?.[0]?.message || d?.message || 'Validation failed';
43
+ return {error: msg, hint: 'Check that the head branch exists and has commits ahead of base'};
44
+ }
45
+ return {error: 'HTTP ' + status};
46
+ }
47
+
48
+ const pr = await resp.json();
49
+ return {
50
+ number: pr.number,
51
+ title: pr.title,
52
+ url: pr.html_url,
53
+ state: pr.state
54
+ };
55
+ }
@@ -0,0 +1,27 @@
1
+ /* @meta
2
+ {
3
+ "name": "github/repo",
4
+ "description": "获取 GitHub 仓库信息",
5
+ "domain": "github.com",
6
+ "args": {
7
+ "repo": {"required": true, "description": "owner/repo format (e.g. epiral/bb-browser)"}
8
+ },
9
+ "capabilities": ["network"],
10
+ "readOnly": true,
11
+ "example": "bb-browser site github/repo epiral/bb-browser"
12
+ }
13
+ */
14
+
15
+ async function(args) {
16
+ if (!args.repo) return {error: 'Missing argument: repo', hint: 'Use owner/repo format'};
17
+ const resp = await fetch('https://api.github.com/repos/' + args.repo, {credentials: 'include'});
18
+ if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: resp.status === 404 ? 'Repo not found: ' + args.repo : 'API error'};
19
+ const d = await resp.json();
20
+ return {
21
+ full_name: d.full_name, description: d.description, language: d.language,
22
+ url: d.html_url || ('https://github.com/' + d.full_name),
23
+ stars: d.stargazers_count, forks: d.forks_count, open_issues: d.open_issues_count,
24
+ created_at: d.created_at, updated_at: d.updated_at, default_branch: d.default_branch,
25
+ topics: d.topics, license: d.license?.spdx_id
26
+ };
27
+ }
@@ -0,0 +1,54 @@
1
+ /* @meta
2
+ {
3
+ "name": "google/search",
4
+ "description": "Google 搜索",
5
+ "domain": "www.google.com",
6
+ "args": {
7
+ "query": {"required": true, "description": "Search query"},
8
+ "count": {"required": false, "description": "Number of results (default 10)"}
9
+ },
10
+ "readOnly": true,
11
+ "example": "bb-browser site google/search \"bb-browser\""
12
+ }
13
+ */
14
+
15
+ async function(args) {
16
+ if (!args.query) return {error: 'Missing argument: query', hint: 'Provide a search query string'};
17
+ const num = args.count || 10;
18
+ const url = 'https://www.google.com/search?q=' + encodeURIComponent(args.query) + '&num=' + num;
19
+ const resp = await fetch(url, {credentials: 'include'});
20
+ if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Make sure a google.com tab is open'};
21
+ const html = await resp.text();
22
+ const doc = new DOMParser().parseFromString(html, 'text/html');
23
+
24
+ // Extract results structurally — no dependency on CSS class names.
25
+ // Each organic result has an h3 (title) inside an <a> (link).
26
+ // For each h3, walk up to find its result container (stops when parent has sibling results).
27
+ const h3s = doc.querySelectorAll('h3');
28
+ const results = [];
29
+ for (const h3 of h3s) {
30
+ const a = h3.closest('a');
31
+ if (!a) continue;
32
+ const link = a.getAttribute('href');
33
+ if (!link || !link.startsWith('http')) continue;
34
+ const title = h3.textContent.trim();
35
+ // Walk up from the link to find the result container
36
+ let container = a;
37
+ while (container.parentElement && container.parentElement.tagName !== 'BODY') {
38
+ const sibs = [...container.parentElement.children];
39
+ if (sibs.filter(s => s.querySelector('h3')).length > 1) break;
40
+ container = container.parentElement;
41
+ }
42
+ // Snippet: first substantial span outside the link block
43
+ let snippet = '';
44
+ const linkBlock = a.closest('div') || a;
45
+ const spans = container.querySelectorAll('span');
46
+ for (const sp of spans) {
47
+ if (linkBlock.contains(sp)) continue;
48
+ const t = sp.textContent.trim();
49
+ if (t.length > 30 && t !== title) { snippet = t; break; }
50
+ }
51
+ results.push({title, url: link, snippet});
52
+ }
53
+ return {query: args.query, count: results.length, results};
54
+ }
@@ -0,0 +1,107 @@
1
+ /* @meta
2
+ {
3
+ "name": "toutiao/hot",
4
+ "description": "今日头条热榜",
5
+ "domain": "www.toutiao.com",
6
+ "args": {
7
+ "count": {"required": false, "description": "返回条数 (默认 20, 最多 50)"}
8
+ },
9
+ "readOnly": true,
10
+ "example": "bb-browser site toutiao/hot"
11
+ }
12
+ */
13
+
14
+ async function(args) {
15
+ const count = Math.min(parseInt(args.count) || 20, 50);
16
+
17
+ const resp = await fetch('https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc', {credentials: 'include'});
18
+ if (!resp.ok) {
19
+ // Fallback: parse hot search from homepage
20
+ return await fallbackFromHomepage(count);
21
+ }
22
+
23
+ let data;
24
+ try {
25
+ data = await resp.json();
26
+ } catch (e) {
27
+ return await fallbackFromHomepage(count);
28
+ }
29
+
30
+ if (!data || !data.data) {
31
+ return await fallbackFromHomepage(count);
32
+ }
33
+
34
+ const items = (data.data || data.fixed_top_data || []).slice(0, count).map((item, i) => ({
35
+ rank: i + 1,
36
+ title: item.Title || item.title || '',
37
+ hot_value: item.HotValue || item.hot_value || 0,
38
+ label: item.Label || item.label || '',
39
+ url: item.Url || item.url || '',
40
+ cluster_id: item.ClusterId || item.cluster_id || ''
41
+ }));
42
+
43
+ return {count: items.length, items};
44
+
45
+ async function fallbackFromHomepage(limit) {
46
+ const homeResp = await fetch('https://www.toutiao.com/', {credentials: 'include'});
47
+ if (!homeResp.ok) return {error: 'HTTP ' + homeResp.status, hint: 'Open www.toutiao.com in bb-browser first'};
48
+
49
+ const html = await homeResp.text();
50
+ const parser = new DOMParser();
51
+ const doc = parser.parseFromString(html, 'text/html');
52
+
53
+ // Try to extract hot search data from SSR HTML
54
+ const items = [];
55
+
56
+ // Method 1: Look for hot search region text
57
+ const allText = doc.body?.textContent || '';
58
+
59
+ // Method 2: Parse script tags for embedded data
60
+ const scripts = doc.querySelectorAll('script:not([src])');
61
+ for (const script of scripts) {
62
+ const text = script.textContent || '';
63
+ if (text.includes('hotBoard') || text.includes('hot_board') || text.includes('HotValue')) {
64
+ try {
65
+ const match = text.match(/\[.*"Title".*\]/s) || text.match(/\[.*"title".*"hot_value".*\]/s);
66
+ if (match) {
67
+ const hotData = JSON.parse(match[0]);
68
+ hotData.slice(0, limit).forEach((item, i) => {
69
+ items.push({
70
+ rank: i + 1,
71
+ title: item.Title || item.title || '',
72
+ hot_value: item.HotValue || item.hot_value || 0,
73
+ label: item.Label || item.label || '',
74
+ url: item.Url || item.url || '',
75
+ cluster_id: item.ClusterId || item.cluster_id || ''
76
+ });
77
+ });
78
+ if (items.length > 0) return {count: items.length, source: 'homepage_script', items};
79
+ }
80
+ } catch (e) {}
81
+ }
82
+ }
83
+
84
+ // Method 3: Parse hot search links from DOM
85
+ const hotLinks = doc.querySelectorAll('a[href*="search"], [class*="hot"] a, [class*="Hot"] a');
86
+ for (const link of hotLinks) {
87
+ const title = (link.textContent || '').trim();
88
+ if (!title || title.length < 2 || title.length > 100) continue;
89
+ if (items.some(it => it.title === title)) continue;
90
+ items.push({
91
+ rank: items.length + 1,
92
+ title,
93
+ hot_value: 0,
94
+ label: '',
95
+ url: link.getAttribute('href') || '',
96
+ cluster_id: ''
97
+ });
98
+ if (items.length >= limit) break;
99
+ }
100
+
101
+ if (items.length === 0) {
102
+ return {error: 'Could not extract hot topics', hint: 'Open www.toutiao.com in bb-browser first and make sure you are logged in'};
103
+ }
104
+
105
+ return {count: items.length, source: 'homepage_dom', items};
106
+ }
107
+ }