scrape-forvo 0.1.0__tar.gz
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.
- scrape_forvo-0.1.0/PKG-INFO +77 -0
- scrape_forvo-0.1.0/README.md +65 -0
- scrape_forvo-0.1.0/pyproject.toml +30 -0
- scrape_forvo-0.1.0/setup.cfg +4 -0
- scrape_forvo-0.1.0/src/scrape_forvo/__init__.py +6 -0
- scrape_forvo-0.1.0/src/scrape_forvo/__main__.py +7 -0
- scrape_forvo-0.1.0/src/scrape_forvo/api.py +127 -0
- scrape_forvo-0.1.0/src/scrape_forvo/cli.py +60 -0
- scrape_forvo-0.1.0/src/scrape_forvo/download.py +43 -0
- scrape_forvo-0.1.0/src/scrape_forvo/errors.py +14 -0
- scrape_forvo-0.1.0/src/scrape_forvo/fetch.py +115 -0
- scrape_forvo-0.1.0/src/scrape_forvo/parse.py +83 -0
- scrape_forvo-0.1.0/src/scrape_forvo/types.py +11 -0
- scrape_forvo-0.1.0/src/scrape_forvo.egg-info/PKG-INFO +77 -0
- scrape_forvo-0.1.0/src/scrape_forvo.egg-info/SOURCES.txt +22 -0
- scrape_forvo-0.1.0/src/scrape_forvo.egg-info/dependency_links.txt +1 -0
- scrape_forvo-0.1.0/src/scrape_forvo.egg-info/entry_points.txt +2 -0
- scrape_forvo-0.1.0/src/scrape_forvo.egg-info/requires.txt +6 -0
- scrape_forvo-0.1.0/src/scrape_forvo.egg-info/top_level.txt +1 -0
- scrape_forvo-0.1.0/tests/test_api.py +38 -0
- scrape_forvo-0.1.0/tests/test_cli.py +23 -0
- scrape_forvo-0.1.0/tests/test_download.py +60 -0
- scrape_forvo-0.1.0/tests/test_fetch_requests.py +44 -0
- scrape_forvo-0.1.0/tests/test_parse.py +38 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrape-forvo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: playwright>=1.58.0
|
|
8
|
+
Requires-Dist: requests>=2.32.5
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
11
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# scrape-forvo
|
|
14
|
+
|
|
15
|
+
Download pronunciation MP3s from Forvo search pages.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install -e .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Only this command is confirmed to work reliably:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
scrape-forvo https://forvo.com/search/egg/no/ --use-playwright --headed
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Scriptable Usage
|
|
32
|
+
|
|
33
|
+
You can also import `scrape_forvo` and use it from Python:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from scrape_forvo import scrape
|
|
37
|
+
|
|
38
|
+
result = scrape(
|
|
39
|
+
"https://forvo.com/search/egg/no/",
|
|
40
|
+
outdir="forvo_mp3",
|
|
41
|
+
use_playwright=True,
|
|
42
|
+
headed=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print(result.downloaded_count)
|
|
46
|
+
for candidate in result.candidates:
|
|
47
|
+
print(candidate.url, "->", candidate.out_path)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The `scrape()` arguments map directly to CLI flags, so both interfaces share the same behavior without duplicated logic.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
Install dev dependencies:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install -e .[dev]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run tests:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pytest
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Optional live test
|
|
67
|
+
|
|
68
|
+
Set `FORVO_LIVE_TEST=1` to enable the live integration test.
|
|
69
|
+
|
|
70
|
+
## TODO
|
|
71
|
+
|
|
72
|
+
edge cases
|
|
73
|
+
- [ ] when multiple pronunciation files come out. which one to pick?
|
|
74
|
+
- [ ] when there's no pronunciation available.
|
|
75
|
+
|
|
76
|
+
integration
|
|
77
|
+
- [ ] integration with the vocab repo
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# scrape-forvo
|
|
2
|
+
|
|
3
|
+
Download pronunciation MP3s from Forvo search pages.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Only this command is confirmed to work reliably:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
scrape-forvo https://forvo.com/search/egg/no/ --use-playwright --headed
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Scriptable Usage
|
|
20
|
+
|
|
21
|
+
You can also import `scrape_forvo` and use it from Python:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from scrape_forvo import scrape
|
|
25
|
+
|
|
26
|
+
result = scrape(
|
|
27
|
+
"https://forvo.com/search/egg/no/",
|
|
28
|
+
outdir="forvo_mp3",
|
|
29
|
+
use_playwright=True,
|
|
30
|
+
headed=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
print(result.downloaded_count)
|
|
34
|
+
for candidate in result.candidates:
|
|
35
|
+
print(candidate.url, "->", candidate.out_path)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The `scrape()` arguments map directly to CLI flags, so both interfaces share the same behavior without duplicated logic.
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
Install dev dependencies:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pip install -e .[dev]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Run tests:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pytest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Optional live test
|
|
55
|
+
|
|
56
|
+
Set `FORVO_LIVE_TEST=1` to enable the live integration test.
|
|
57
|
+
|
|
58
|
+
## TODO
|
|
59
|
+
|
|
60
|
+
edge cases
|
|
61
|
+
- [ ] when multiple pronunciation files come out. which one to pick?
|
|
62
|
+
- [ ] when there's no pronunciation available.
|
|
63
|
+
|
|
64
|
+
integration
|
|
65
|
+
- [ ] integration with the vocab repo
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scrape-forvo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"playwright>=1.58.0",
|
|
9
|
+
"requests>=2.32.5",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
scrape-forvo = "scrape_forvo.cli:main"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0.0",
|
|
18
|
+
"responses>=0.25.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["setuptools>=69.0.0"]
|
|
23
|
+
build-backend = "setuptools.build_meta"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = {"" = "src"}
|
|
27
|
+
packages = ["scrape_forvo"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .download import download_audio, select_audio_url
|
|
10
|
+
from .fetch import fetch_html_playwright, fetch_html_requests, make_session
|
|
11
|
+
from .parse import extract_audio_host, iter_play_items, page_slug, safe_filename
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AudioCandidate:
|
|
16
|
+
play_id: str
|
|
17
|
+
label: str
|
|
18
|
+
url: str
|
|
19
|
+
out_path: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ScrapeResult:
|
|
24
|
+
downloaded_count: int
|
|
25
|
+
candidates: tuple[AudioCandidate, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def scrape(
|
|
29
|
+
url: str,
|
|
30
|
+
*,
|
|
31
|
+
outdir: str = "forvo_mp3",
|
|
32
|
+
limit: int = 1000,
|
|
33
|
+
dry_run: bool = False,
|
|
34
|
+
no_head: bool = False,
|
|
35
|
+
prefix: str | None = None,
|
|
36
|
+
use_playwright: bool = True,
|
|
37
|
+
headed: bool = False,
|
|
38
|
+
emit: Optional[Callable[[str], None]] = None,
|
|
39
|
+
) -> ScrapeResult:
|
|
40
|
+
"""Scrape pronunciation audio (MP3) from a Forvo page and optionally download.
|
|
41
|
+
|
|
42
|
+
Fetches the page, parses play items, resolves MP3 URLs, and downloads files
|
|
43
|
+
into ``outdir``. Skips duplicates and failed downloads; continues until
|
|
44
|
+
``limit`` items are downloaded or the page is exhausted.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
url: Forvo pronunciation page URL to scrape.
|
|
48
|
+
outdir: Directory to save MP3 files. Created if missing.
|
|
49
|
+
limit: Maximum number of audio files to download (0 = no limit).
|
|
50
|
+
dry_run: If True, do not download; only collect candidates and count.
|
|
51
|
+
no_head: If True, skip HEAD checks when resolving audio URLs.
|
|
52
|
+
prefix: Filename prefix; if None, derived from the page URL slug.
|
|
53
|
+
use_playwright: If True, fetch HTML with Playwright instead of requests.
|
|
54
|
+
headed: If True, run Playwright browser in headed (visible) mode.
|
|
55
|
+
emit: Optional callback called with progress strings (URLs, paths, skips).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
ScrapeResult with downloaded_count and candidates (all considered items).
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If limit is negative.
|
|
62
|
+
"""
|
|
63
|
+
if limit < 0:
|
|
64
|
+
raise ValueError("limit must be >= 0")
|
|
65
|
+
|
|
66
|
+
name_prefix = safe_filename(prefix) if prefix else page_slug(url)
|
|
67
|
+
session = make_session()
|
|
68
|
+
if use_playwright:
|
|
69
|
+
html = fetch_html_playwright(url, headed=headed)
|
|
70
|
+
else:
|
|
71
|
+
html = fetch_html_requests(url, session)
|
|
72
|
+
|
|
73
|
+
audio_host = extract_audio_host(html)
|
|
74
|
+
base_url = f"https://{audio_host}/audios/mp3"
|
|
75
|
+
|
|
76
|
+
downloaded = 0
|
|
77
|
+
seen_urls: set[str] = set()
|
|
78
|
+
candidates: list[AudioCandidate] = []
|
|
79
|
+
|
|
80
|
+
for item in iter_play_items(html):
|
|
81
|
+
if downloaded >= limit:
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
chosen_url = select_audio_url(
|
|
85
|
+
item,
|
|
86
|
+
base_url,
|
|
87
|
+
session,
|
|
88
|
+
no_head=no_head,
|
|
89
|
+
seen_urls=seen_urls,
|
|
90
|
+
)
|
|
91
|
+
if not chosen_url:
|
|
92
|
+
if emit:
|
|
93
|
+
emit(f"[skip] play_id={item.play_id} label={item.label} (no working mp3 URL)")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
seen_urls.add(chosen_url)
|
|
97
|
+
filename = f"{name_prefix}_{safe_filename(item.label)}_{item.play_id}_{downloaded+1:03d}.mp3"
|
|
98
|
+
out_path = os.path.join(outdir, filename)
|
|
99
|
+
candidates.append(
|
|
100
|
+
AudioCandidate(
|
|
101
|
+
play_id=item.play_id,
|
|
102
|
+
label=item.label,
|
|
103
|
+
url=chosen_url,
|
|
104
|
+
out_path=out_path,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if emit:
|
|
109
|
+
emit(chosen_url)
|
|
110
|
+
|
|
111
|
+
if dry_run:
|
|
112
|
+
downloaded += 1
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
download_audio(chosen_url, out_path, session)
|
|
117
|
+
if emit:
|
|
118
|
+
emit(f" -> {out_path}")
|
|
119
|
+
downloaded += 1
|
|
120
|
+
except requests.HTTPError as exc:
|
|
121
|
+
if emit:
|
|
122
|
+
emit(f"[skip] download failed for {chosen_url}: {exc}")
|
|
123
|
+
except requests.RequestException as exc:
|
|
124
|
+
if emit:
|
|
125
|
+
emit(f"[skip] network error for {chosen_url}: {exc}")
|
|
126
|
+
|
|
127
|
+
return ScrapeResult(downloaded_count=downloaded, candidates=tuple(candidates))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from .api import scrape
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _configure_logging() -> logging.Logger:
|
|
12
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True, stream=sys.stdout)
|
|
13
|
+
return logging.getLogger("scrape_forvo")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_args(argv: Iterable[str] | None) -> argparse.Namespace:
|
|
17
|
+
ap = argparse.ArgumentParser()
|
|
18
|
+
ap.add_argument("url", help="Forvo URL, e.g. https://forvo.com/search/egg/no/")
|
|
19
|
+
ap.add_argument("-o", "--outdir", default="forvo_mp3", help="Output directory")
|
|
20
|
+
ap.add_argument("--limit", type=int, default=1000, help="Max downloads")
|
|
21
|
+
ap.add_argument("--dry-run", action="store_true", help="Print only; do not download")
|
|
22
|
+
ap.add_argument("--no-head", action="store_true", help="Skip HEAD probe; try GET directly")
|
|
23
|
+
ap.add_argument("--prefix", default=None, help="Filename prefix (default: derived from URL)")
|
|
24
|
+
ap.add_argument(
|
|
25
|
+
"--use-playwright",
|
|
26
|
+
action="store_true",
|
|
27
|
+
help="Use Playwright to fetch HTML (bypasses many 403 blocks).",
|
|
28
|
+
)
|
|
29
|
+
ap.add_argument(
|
|
30
|
+
"--headed",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="Show browser window (use if Cloudflare blocks headless; challenge often passes when visible).",
|
|
33
|
+
)
|
|
34
|
+
return ap.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
38
|
+
args = _parse_args(argv)
|
|
39
|
+
log = _configure_logging()
|
|
40
|
+
result = scrape(
|
|
41
|
+
args.url,
|
|
42
|
+
outdir=args.outdir,
|
|
43
|
+
limit=args.limit,
|
|
44
|
+
dry_run=args.dry_run,
|
|
45
|
+
no_head=args.no_head,
|
|
46
|
+
prefix=args.prefix,
|
|
47
|
+
use_playwright=args.use_playwright,
|
|
48
|
+
headed=args.headed,
|
|
49
|
+
emit=log.info,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if result.downloaded_count == 0:
|
|
53
|
+
log.info("No MP3s downloaded (no valid Play(...) mp3 URLs found).")
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .types import PlayItem
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def head_ok(url: str, session: requests.Session) -> bool:
|
|
12
|
+
try:
|
|
13
|
+
r = session.head(url, allow_redirects=True, timeout=15)
|
|
14
|
+
return r.status_code in (200, 206)
|
|
15
|
+
except requests.RequestException:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def select_audio_url(
|
|
20
|
+
item: PlayItem,
|
|
21
|
+
base_url: str,
|
|
22
|
+
session: requests.Session,
|
|
23
|
+
*,
|
|
24
|
+
no_head: bool,
|
|
25
|
+
seen_urls: set[str],
|
|
26
|
+
) -> Optional[str]:
|
|
27
|
+
for mp3_path in item.mp3_paths:
|
|
28
|
+
url = f"{base_url}/{mp3_path}"
|
|
29
|
+
if url in seen_urls:
|
|
30
|
+
continue
|
|
31
|
+
if no_head or head_ok(url, session):
|
|
32
|
+
return url
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def download_audio(url: str, out_path: str, session: requests.Session) -> None:
|
|
37
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
38
|
+
with session.get(url, stream=True, timeout=30) as r:
|
|
39
|
+
r.raise_for_status()
|
|
40
|
+
with open(out_path, "wb") as f:
|
|
41
|
+
for chunk in r.iter_content(chunk_size=1024 * 128):
|
|
42
|
+
if chunk:
|
|
43
|
+
f.write(chunk)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ForvoError(RuntimeError):
|
|
5
|
+
"""Base error for scrape_forvo."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContentNotFoundError(ForvoError):
|
|
9
|
+
"""Expected content was not found in page HTML."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ForvoBlockedError(ForvoError):
|
|
13
|
+
"""Raised when Forvo blocks non-browser requests (e.g., 403)."""
|
|
14
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from .errors import ForvoBlockedError
|
|
7
|
+
_REAL_CONTENT_MARKER = "_AUDIO_HTTP_HOST"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_session() -> requests.Session:
|
|
11
|
+
"""
|
|
12
|
+
Make a more browser-like session to avoid 403s.
|
|
13
|
+
"""
|
|
14
|
+
s = requests.Session()
|
|
15
|
+
s.headers.update(
|
|
16
|
+
{
|
|
17
|
+
"User-Agent": (
|
|
18
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
19
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
20
|
+
"Chrome/122.0.0.0 Safari/537.36"
|
|
21
|
+
),
|
|
22
|
+
"Accept": (
|
|
23
|
+
"text/html,application/xhtml+xml,application/xml;"
|
|
24
|
+
"q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
|
|
25
|
+
),
|
|
26
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
27
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
28
|
+
"Connection": "keep-alive",
|
|
29
|
+
"Upgrade-Insecure-Requests": "1",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
return s
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_html_requests(url: str, session: requests.Session) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Requests-based fetch with:
|
|
38
|
+
- warm-up homepage (cookies)
|
|
39
|
+
- referer header
|
|
40
|
+
"""
|
|
41
|
+
homepage = "https://forvo.com/"
|
|
42
|
+
try:
|
|
43
|
+
session.get(homepage, timeout=20)
|
|
44
|
+
except requests.RequestException:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
headers = {"Referer": homepage}
|
|
48
|
+
r = session.get(url, headers=headers, timeout=30)
|
|
49
|
+
|
|
50
|
+
if r.status_code == 403:
|
|
51
|
+
raise ForvoBlockedError(
|
|
52
|
+
"HTTP 403 Forbidden. Forvo is blocking non-browser requests.\n"
|
|
53
|
+
"Try:\n"
|
|
54
|
+
" 1) --use-playwright (recommended)\n"
|
|
55
|
+
" 2) run from a different network/IP\n"
|
|
56
|
+
" 3) ensure you can open the URL in a normal browser\n"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
r.raise_for_status()
|
|
60
|
+
return r.text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wait_for_real_content(page, timeout_ms: int = 60_000, poll_interval_ms: int = 2000) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Wait until the page shows real Forvo content (past Cloudflare/security interstitial).
|
|
66
|
+
Returns final HTML. Raises RuntimeError if timeout is reached before real content appears.
|
|
67
|
+
"""
|
|
68
|
+
deadline = time.time() + (timeout_ms / 1000.0)
|
|
69
|
+
while time.time() < deadline:
|
|
70
|
+
html = page.content()
|
|
71
|
+
if _REAL_CONTENT_MARKER in html:
|
|
72
|
+
return html
|
|
73
|
+
page.wait_for_timeout(min(poll_interval_ms, 2000))
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"Timed out waiting for Forvo to finish security verification. "
|
|
76
|
+
"Try: python3 -m scrape_forvo ... --use-playwright --headed (visible browser often passes Cloudflare)."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fetch_html_playwright(
|
|
81
|
+
url: str,
|
|
82
|
+
*,
|
|
83
|
+
headed: bool = False,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Playwright fallback: renders page like a real browser and returns final HTML.
|
|
87
|
+
Requires:
|
|
88
|
+
pip install playwright
|
|
89
|
+
playwright install
|
|
90
|
+
|
|
91
|
+
Waits for Cloudflare/security verification to complete before reading content.
|
|
92
|
+
Use headed=True to show the browser window; Cloudflare often passes with a visible browser.
|
|
93
|
+
"""
|
|
94
|
+
from playwright.sync_api import sync_playwright
|
|
95
|
+
|
|
96
|
+
with sync_playwright() as p:
|
|
97
|
+
browser = p.chromium.launch(headless=not headed)
|
|
98
|
+
try:
|
|
99
|
+
ctx = browser.new_context(locale="en-US")
|
|
100
|
+
page = ctx.new_page()
|
|
101
|
+
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
|
|
102
|
+
page.wait_for_timeout(2000)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
html = _wait_for_real_content(page, timeout_ms=55_000, poll_interval_ms=2000)
|
|
106
|
+
except RuntimeError:
|
|
107
|
+
html = page.content()
|
|
108
|
+
if _REAL_CONTENT_MARKER not in html:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Page did not load real Forvo content (still on security verification or captcha). "
|
|
111
|
+
"Try: --use-playwright --headed (visible browser), or run from a different network."
|
|
112
|
+
)
|
|
113
|
+
return html
|
|
114
|
+
finally:
|
|
115
|
+
browser.close()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
from typing import Iterable, List, Optional
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from .errors import ContentNotFoundError
|
|
9
|
+
from .types import PlayItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
AUDIO_HOST_RE = re.compile(r"_AUDIO_HTTP_HOST\s*=\s*'([^']+)'")
|
|
13
|
+
ONCLICK_PLAY_RE = re.compile(r'onclick="Play\((.*?)\);return false;"', re.DOTALL)
|
|
14
|
+
SINGLE_QUOTED_RE = re.compile(r"'([^']*)'")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def b64_decode(s: str) -> Optional[str]:
|
|
18
|
+
try:
|
|
19
|
+
padded = s + "=" * (-len(s) % 4)
|
|
20
|
+
raw = base64.b64decode(padded)
|
|
21
|
+
return raw.decode("utf-8")
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_audio_host(html: str) -> str:
|
|
27
|
+
m = AUDIO_HOST_RE.search(html)
|
|
28
|
+
if not m:
|
|
29
|
+
raise ContentNotFoundError("Could not find _AUDIO_HTTP_HOST in page HTML.")
|
|
30
|
+
return m.group(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def safe_filename(name: str) -> str:
|
|
34
|
+
name = name.strip()
|
|
35
|
+
name = re.sub(r"\s+", "_", name)
|
|
36
|
+
name = re.sub(r"[^a-zA-Z0-9._-]+", "_", name)
|
|
37
|
+
return name.strip("_") or "forvo"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def page_slug(url: str) -> str:
|
|
41
|
+
p = urlparse(url)
|
|
42
|
+
bits = [b for b in p.path.split("/") if b]
|
|
43
|
+
return safe_filename(bits[-1] if bits else "forvo")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dedupe_preserve_order(paths: Iterable[str]) -> List[str]:
|
|
47
|
+
seen = set()
|
|
48
|
+
uniq: List[str] = []
|
|
49
|
+
for p in paths:
|
|
50
|
+
if p not in seen:
|
|
51
|
+
seen.add(p)
|
|
52
|
+
uniq.append(p)
|
|
53
|
+
return uniq
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prefer_canonical(paths: List[str]) -> List[str]:
|
|
57
|
+
paths.sort(key=lambda p: (0 if re.search(r"(^|/)2/s/2s_", p) else 1, len(p)))
|
|
58
|
+
return paths
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def iter_play_items(html: str) -> Iterable[PlayItem]:
|
|
62
|
+
for m in ONCLICK_PLAY_RE.finditer(html):
|
|
63
|
+
inside = m.group(1)
|
|
64
|
+
|
|
65
|
+
play_id_match = re.match(r"\s*(\d+)\s*,", inside)
|
|
66
|
+
play_id = play_id_match.group(1) if play_id_match else "unknown"
|
|
67
|
+
|
|
68
|
+
quoted = SINGLE_QUOTED_RE.findall(inside)
|
|
69
|
+
|
|
70
|
+
label = "forvo"
|
|
71
|
+
if len(quoted) >= 2:
|
|
72
|
+
label = quoted[-2] or quoted[-1] or "forvo"
|
|
73
|
+
|
|
74
|
+
decoded_mp3_paths: List[str] = []
|
|
75
|
+
for token in quoted:
|
|
76
|
+
decoded = b64_decode(token)
|
|
77
|
+
if decoded and ".mp3" in decoded:
|
|
78
|
+
decoded_mp3_paths.append(decoded.lstrip("/"))
|
|
79
|
+
|
|
80
|
+
uniq = _prefer_canonical(_dedupe_preserve_order(decoded_mp3_paths))
|
|
81
|
+
|
|
82
|
+
if uniq:
|
|
83
|
+
yield PlayItem(play_id=play_id, label=label, mp3_paths=tuple(uniq))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrape-forvo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: playwright>=1.58.0
|
|
8
|
+
Requires-Dist: requests>=2.32.5
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
11
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# scrape-forvo
|
|
14
|
+
|
|
15
|
+
Download pronunciation MP3s from Forvo search pages.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install -e .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Only this command is confirmed to work reliably:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
scrape-forvo https://forvo.com/search/egg/no/ --use-playwright --headed
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Scriptable Usage
|
|
32
|
+
|
|
33
|
+
You can also import `scrape_forvo` and use it from Python:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from scrape_forvo import scrape
|
|
37
|
+
|
|
38
|
+
result = scrape(
|
|
39
|
+
"https://forvo.com/search/egg/no/",
|
|
40
|
+
outdir="forvo_mp3",
|
|
41
|
+
use_playwright=True,
|
|
42
|
+
headed=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print(result.downloaded_count)
|
|
46
|
+
for candidate in result.candidates:
|
|
47
|
+
print(candidate.url, "->", candidate.out_path)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The `scrape()` arguments map directly to CLI flags, so both interfaces share the same behavior without duplicated logic.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
Install dev dependencies:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install -e .[dev]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run tests:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pytest
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Optional live test
|
|
67
|
+
|
|
68
|
+
Set `FORVO_LIVE_TEST=1` to enable the live integration test.
|
|
69
|
+
|
|
70
|
+
## TODO
|
|
71
|
+
|
|
72
|
+
edge cases
|
|
73
|
+
- [ ] when multiple pronunciation files come out. which one to pick?
|
|
74
|
+
- [ ] when there's no pronunciation available.
|
|
75
|
+
|
|
76
|
+
integration
|
|
77
|
+
- [ ] integration with the vocab repo
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/scrape_forvo/__init__.py
|
|
4
|
+
src/scrape_forvo/__main__.py
|
|
5
|
+
src/scrape_forvo/api.py
|
|
6
|
+
src/scrape_forvo/cli.py
|
|
7
|
+
src/scrape_forvo/download.py
|
|
8
|
+
src/scrape_forvo/errors.py
|
|
9
|
+
src/scrape_forvo/fetch.py
|
|
10
|
+
src/scrape_forvo/parse.py
|
|
11
|
+
src/scrape_forvo/types.py
|
|
12
|
+
src/scrape_forvo.egg-info/PKG-INFO
|
|
13
|
+
src/scrape_forvo.egg-info/SOURCES.txt
|
|
14
|
+
src/scrape_forvo.egg-info/dependency_links.txt
|
|
15
|
+
src/scrape_forvo.egg-info/entry_points.txt
|
|
16
|
+
src/scrape_forvo.egg-info/requires.txt
|
|
17
|
+
src/scrape_forvo.egg-info/top_level.txt
|
|
18
|
+
tests/test_api.py
|
|
19
|
+
tests/test_cli.py
|
|
20
|
+
tests/test_download.py
|
|
21
|
+
tests/test_fetch_requests.py
|
|
22
|
+
tests/test_parse.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scrape_forvo
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
import scrape_forvo.api as api
|
|
6
|
+
from scrape_forvo.types import PlayItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _b64(s: str) -> str:
|
|
10
|
+
return base64.b64encode(s.encode()).decode()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sample_html() -> str:
|
|
14
|
+
mp3 = _b64("2/s/2s_egg_1.mp3")
|
|
15
|
+
return (
|
|
16
|
+
"var _AUDIO_HTTP_HOST = 'audio.forvo.com';"
|
|
17
|
+
"<a onclick=\"Play(123,'x','%s','Label');return false;\">" % mp3
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_scrape_dry_run_returns_candidate(monkeypatch) -> None:
|
|
22
|
+
monkeypatch.setattr(api, "make_session", lambda: object())
|
|
23
|
+
monkeypatch.setattr(api, "fetch_html_requests", lambda url, session: _sample_html())
|
|
24
|
+
monkeypatch.setattr(api, "iter_play_items", lambda html: iter((PlayItem("123", "Label", ("2/s/2s_egg_1.mp3",)),)))
|
|
25
|
+
|
|
26
|
+
result = api.scrape("https://forvo.com/search/egg/no/", dry_run=True, no_head=True)
|
|
27
|
+
assert result.downloaded_count == 1
|
|
28
|
+
assert len(result.candidates) == 1
|
|
29
|
+
assert result.candidates[0].url == "https://audio.forvo.com/audios/mp3/2/s/2s_egg_1.mp3"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_scrape_invalid_limit() -> None:
|
|
33
|
+
try:
|
|
34
|
+
api.scrape("https://forvo.com/search/egg/no/", limit=-1)
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
assert "limit must be >= 0" in str(exc)
|
|
37
|
+
else:
|
|
38
|
+
raise AssertionError("Expected ValueError")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import scrape_forvo.cli as cli
|
|
4
|
+
from scrape_forvo.api import ScrapeResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_cli_dry_run(monkeypatch, capsys) -> None:
|
|
8
|
+
monkeypatch.setattr(cli, "scrape", lambda *args, **kwargs: ScrapeResult(downloaded_count=1, candidates=tuple()))
|
|
9
|
+
|
|
10
|
+
code = cli.main(["https://forvo.com/search/egg/no/", "--dry-run", "--no-head"])
|
|
11
|
+
assert code == 0
|
|
12
|
+
|
|
13
|
+
_ = capsys.readouterr()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_cli_no_mp3s(monkeypatch, capsys) -> None:
|
|
17
|
+
monkeypatch.setattr(cli, "scrape", lambda *args, **kwargs: ScrapeResult(downloaded_count=0, candidates=tuple()))
|
|
18
|
+
|
|
19
|
+
code = cli.main(["https://forvo.com/search/egg/no/", "--dry-run", "--no-head"])
|
|
20
|
+
assert code == 2
|
|
21
|
+
|
|
22
|
+
captured = capsys.readouterr()
|
|
23
|
+
assert "No MP3s downloaded" in (captured.out + captured.err)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from scrape_forvo.download import download_audio, select_audio_url
|
|
8
|
+
from scrape_forvo.types import PlayItem
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _DummyResponse:
|
|
12
|
+
def __init__(self, status_code: int, body: bytes = b"") -> None:
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self._body = body
|
|
15
|
+
|
|
16
|
+
def raise_for_status(self) -> None:
|
|
17
|
+
if self.status_code >= 400:
|
|
18
|
+
raise RuntimeError("bad status")
|
|
19
|
+
|
|
20
|
+
def iter_content(self, chunk_size: int = 1024) -> list[bytes]:
|
|
21
|
+
return [self._body]
|
|
22
|
+
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __exit__(self, exc_type, exc, tb):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _DummySession:
|
|
31
|
+
def __init__(self, head_status: int = 200, body: bytes = b"") -> None:
|
|
32
|
+
self._head_status = head_status
|
|
33
|
+
self._body = body
|
|
34
|
+
|
|
35
|
+
def head(self, url: str, allow_redirects: bool = True, timeout: int = 15):
|
|
36
|
+
return _DummyResponse(self._head_status)
|
|
37
|
+
|
|
38
|
+
def get(self, url: str, stream: bool = True, timeout: int = 30):
|
|
39
|
+
return _DummyResponse(200, self._body)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_select_audio_url_respects_no_head() -> None:
|
|
43
|
+
item = PlayItem(play_id="1", label="x", mp3_paths=("a.mp3", "b.mp3"))
|
|
44
|
+
session = _DummySession(head_status=404)
|
|
45
|
+
url = select_audio_url(item, "https://host/audios/mp3", session, no_head=True, seen_urls=set())
|
|
46
|
+
assert url == "https://host/audios/mp3/a.mp3"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_select_audio_url_uses_head() -> None:
|
|
50
|
+
item = PlayItem(play_id="1", label="x", mp3_paths=("a.mp3",))
|
|
51
|
+
session = _DummySession(head_status=404)
|
|
52
|
+
url = select_audio_url(item, "https://host/audios/mp3", session, no_head=False, seen_urls=set())
|
|
53
|
+
assert url is None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_download_audio_writes_file(tmp_path: Path) -> None:
|
|
57
|
+
session = _DummySession(body=b"abc123")
|
|
58
|
+
out_path = tmp_path / "out.mp3"
|
|
59
|
+
download_audio("https://host/audios/mp3/a.mp3", str(out_path), session)
|
|
60
|
+
assert out_path.read_bytes() == b"abc123"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
responses = pytest.importorskip("responses")
|
|
6
|
+
|
|
7
|
+
from scrape_forvo.errors import ForvoBlockedError
|
|
8
|
+
from scrape_forvo.fetch import fetch_html_requests, make_session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@responses.activate
|
|
12
|
+
def test_fetch_html_requests_sets_referer_and_warmup() -> None:
|
|
13
|
+
session = make_session()
|
|
14
|
+
homepage = "https://forvo.com/"
|
|
15
|
+
target = "https://forvo.com/search/egg/no/"
|
|
16
|
+
|
|
17
|
+
responses.add(responses.GET, homepage, body="ok", status=200)
|
|
18
|
+
responses.add(responses.GET, target, body="<html>ok</html>", status=200)
|
|
19
|
+
|
|
20
|
+
html = fetch_html_requests(target, session)
|
|
21
|
+
assert html == "<html>ok</html>"
|
|
22
|
+
|
|
23
|
+
called_urls = [call.request.url for call in responses.calls]
|
|
24
|
+
assert homepage in called_urls
|
|
25
|
+
assert target in called_urls
|
|
26
|
+
|
|
27
|
+
target_call = next(call for call in responses.calls if call.request.url == target)
|
|
28
|
+
assert target_call.request.headers.get("Referer") == homepage
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@responses.activate
|
|
32
|
+
def test_fetch_html_requests_403_raises() -> None:
|
|
33
|
+
session = make_session()
|
|
34
|
+
target = "https://forvo.com/search/egg/no/"
|
|
35
|
+
|
|
36
|
+
responses.add(responses.GET, "https://forvo.com/", body="ok", status=200)
|
|
37
|
+
responses.add(responses.GET, target, body="nope", status=403)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
fetch_html_requests(target, session)
|
|
41
|
+
except ForvoBlockedError as exc:
|
|
42
|
+
assert "HTTP 403" in str(exc)
|
|
43
|
+
else:
|
|
44
|
+
raise AssertionError("Expected ForvoBlockedError")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from scrape_forvo.parse import extract_audio_host, iter_play_items, page_slug, safe_filename
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _b64(s: str) -> str:
|
|
11
|
+
return base64.b64encode(s.encode()).decode()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_extract_audio_host() -> None:
|
|
15
|
+
html = "var _AUDIO_HTTP_HOST = 'audio.forvo.com';"
|
|
16
|
+
assert extract_audio_host(html) == "audio.forvo.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_iter_play_items_dedupe_and_order() -> None:
|
|
20
|
+
mp3_a = _b64("2/s/2s_egg_1.mp3")
|
|
21
|
+
mp3_b = _b64("other/egg_1.mp3")
|
|
22
|
+
html = (
|
|
23
|
+
"<a onclick=\"Play(123,'x','%s','%s','Label','end');return false;\">"
|
|
24
|
+
% (mp3_b, mp3_a)
|
|
25
|
+
)
|
|
26
|
+
items = list(iter_play_items(html))
|
|
27
|
+
assert len(items) == 1
|
|
28
|
+
item = items[0]
|
|
29
|
+
assert item.play_id == "123"
|
|
30
|
+
assert item.label == "Label"
|
|
31
|
+
assert item.mp3_paths[0].startswith("2/s/2s_")
|
|
32
|
+
assert len(item.mp3_paths) == 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_safe_filename_and_page_slug() -> None:
|
|
36
|
+
assert safe_filename(" hello world ") == "hello_world"
|
|
37
|
+
assert safe_filename("##") == "forvo"
|
|
38
|
+
assert page_slug("https://forvo.com/search/egg/no/") == "no"
|